feat: add persistent workbar browser tabs and scoped MCP routing
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentRequestSchema,
|
||||
browserCloseTabRequestSchema,
|
||||
browserCreateTabRequestSchema,
|
||||
browserLiveStateSchema,
|
||||
browserNavigateRequestSchema,
|
||||
browserSetViewportRequestSchema,
|
||||
browserStopLoadingRequestSchema,
|
||||
builtinEmbeddingConnectionId,
|
||||
clipboardTextSchema,
|
||||
@@ -23,9 +26,14 @@ describe('clipboard text contract', () => {
|
||||
})
|
||||
|
||||
describe('browser control contracts', () => {
|
||||
const tabId = '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
|
||||
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
|
||||
const leaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
|
||||
|
||||
it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => {
|
||||
const base = {
|
||||
conversationId: 'conversation',
|
||||
tabId,
|
||||
status: 'ready' as const,
|
||||
sessionActive: true,
|
||||
isLoading: false,
|
||||
@@ -51,10 +59,12 @@ describe('browser control contracts', () => {
|
||||
expect(
|
||||
browserNavigateRequestSchema.parse({
|
||||
conversationId: 'conversation',
|
||||
tabId,
|
||||
url: 'https://example.com/'
|
||||
})
|
||||
).toEqual({
|
||||
conversationId: 'conversation',
|
||||
tabId,
|
||||
url: 'https://example.com/'
|
||||
})
|
||||
expect(
|
||||
@@ -70,6 +80,48 @@ describe('browser control contracts', () => {
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses opaque tab IDs and validates complete viewport ownership leases', () => {
|
||||
expect(
|
||||
browserCreateTabRequestSchema.parse({
|
||||
conversationId: 'conversation',
|
||||
workbarInstanceId
|
||||
})
|
||||
).toEqual({ conversationId: 'conversation', workbarInstanceId })
|
||||
expect(
|
||||
browserCloseTabRequestSchema.safeParse({
|
||||
conversationId: 'conversation',
|
||||
tabId
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
browserCloseTabRequestSchema.safeParse({
|
||||
conversationId: 'conversation',
|
||||
tabId: 'predictable-tab-name'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
browserSetViewportRequestSchema.safeParse({
|
||||
conversationId: 'conversation',
|
||||
tabId,
|
||||
leaseToken,
|
||||
bounds: { x: 1, y: 2, width: 300, height: 400 }
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
browserSetViewportRequestSchema.safeParse({ tabId, leaseToken }).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
browserLiveStateSchema.safeParse({
|
||||
conversationId: 'conversation',
|
||||
status: 'ready',
|
||||
sessionActive: true,
|
||||
isLoading: false,
|
||||
canGoBack: false,
|
||||
updatedAt: 1
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const baseInput = {
|
||||
|
||||
+185
-37
@@ -30,8 +30,6 @@ import {
|
||||
actorConversationSubagentActivitySchema,
|
||||
legacyConversationSubagentActivitySchema,
|
||||
legacyWorkModeSchema,
|
||||
maximumConversationHistoryCharacters,
|
||||
maximumConversationHistoryMessages,
|
||||
type AssistantProject,
|
||||
type AssistantArtifact,
|
||||
type AssistantMemory,
|
||||
@@ -346,11 +344,9 @@ export const agentRequestSchema = z
|
||||
contextIds: z.array(z.string().uuid()).max(8).optional(),
|
||||
history: z
|
||||
.array(conversationHistoryMessageSchema)
|
||||
.max(maximumConversationHistoryMessages)
|
||||
.optional(),
|
||||
historyMessageIds: z
|
||||
.array(z.string().uuid())
|
||||
.max(maximumConversationHistoryMessages)
|
||||
.optional(),
|
||||
currentUserMessageId: z.string().uuid().optional(),
|
||||
currentAssistantMessageId: z.string().uuid().optional(),
|
||||
@@ -359,18 +355,6 @@ export const agentRequestSchema = z
|
||||
})
|
||||
.strict()
|
||||
.superRefine((request, context) => {
|
||||
const historyLength =
|
||||
request.history?.reduce(
|
||||
(total, message) => total + message.content.length,
|
||||
0
|
||||
) ?? 0
|
||||
if (historyLength > maximumConversationHistoryCharacters) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['history'],
|
||||
message: `会话历史总长度不能超过 ${maximumConversationHistoryCharacters.toLocaleString()} 个字符`
|
||||
})
|
||||
}
|
||||
if (
|
||||
request.historyMessageIds &&
|
||||
request.historyMessageIds.length !==
|
||||
@@ -683,6 +667,7 @@ const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
|
||||
])
|
||||
|
||||
export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('default') }).strict(),
|
||||
z.object({ kind: z.literal('platform') }).strict(),
|
||||
z
|
||||
.object({
|
||||
@@ -1373,18 +1358,28 @@ export type AppInfo = {
|
||||
shortcutStatus?: GlobalShortcutRegistrationStatus
|
||||
}
|
||||
|
||||
export const browserTabIdSchema = z.string().uuid().brand<'BrowserTabId'>()
|
||||
export type BrowserTabId = z.infer<typeof browserTabIdSchema>
|
||||
|
||||
export const browserWorkbarInstanceIdSchema = z.string().uuid()
|
||||
export const browserViewportLeaseTokenSchema = z.string().uuid()
|
||||
|
||||
const browserStatusSchema = z.enum([
|
||||
'creating',
|
||||
'loading',
|
||||
'ready',
|
||||
'acting',
|
||||
'interactive',
|
||||
'failed',
|
||||
'stopped'
|
||||
])
|
||||
|
||||
export const browserLiveStateSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
status: z.enum([
|
||||
'creating',
|
||||
'loading',
|
||||
'ready',
|
||||
'acting',
|
||||
'interactive',
|
||||
'failed',
|
||||
'stopped'
|
||||
]),
|
||||
tabId: browserTabIdSchema,
|
||||
ownerWindowId: z.number().int().nonnegative().optional(),
|
||||
status: browserStatusSchema,
|
||||
sessionActive: z.boolean(),
|
||||
isLoading: z.boolean(),
|
||||
canGoBack: z.boolean(),
|
||||
@@ -1404,19 +1399,73 @@ export const browserLiveStateSchema = z
|
||||
|
||||
export type BrowserLiveState = z.infer<typeof browserLiveStateSchema>
|
||||
|
||||
export const browserTabSummarySchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
tabId: browserTabIdSchema,
|
||||
primary: z.boolean(),
|
||||
status: browserStatusSchema.exclude(['stopped']),
|
||||
isLoading: z.boolean(),
|
||||
canGoBack: z.boolean(),
|
||||
url: z.string().max(8_192).optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
updatedAt: z.number().int().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type BrowserTabSummary = z.infer<typeof browserTabSummarySchema>
|
||||
|
||||
export const browserCreateTabRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
workbarInstanceId: browserWorkbarInstanceIdSchema
|
||||
})
|
||||
.strict()
|
||||
export type BrowserCreateTabRequest = z.infer<typeof browserCreateTabRequestSchema>
|
||||
|
||||
export const browserListTabsRequestSchema = z
|
||||
.object({ conversationId: conversationIdSchema })
|
||||
.strict()
|
||||
export type BrowserListTabsRequest = z.infer<typeof browserListTabsRequestSchema>
|
||||
|
||||
export const browserTabRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
tabId: browserTabIdSchema
|
||||
})
|
||||
.strict()
|
||||
export type BrowserTabRequest = z.infer<typeof browserTabRequestSchema>
|
||||
|
||||
export const browserCloseTabRequestSchema = browserTabRequestSchema
|
||||
export type BrowserCloseTabRequest = BrowserTabRequest
|
||||
|
||||
export const browserStopRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const browserBackRequestSchema = browserStopRequestSchema
|
||||
export const browserReloadRequestSchema = browserStopRequestSchema
|
||||
export const browserStopLoadingRequestSchema = browserStopRequestSchema
|
||||
const browserCompatibleTabRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
tabId: browserTabIdSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const browserBackTabRequestSchema = browserCompatibleTabRequestSchema
|
||||
export const browserReloadTabRequestSchema = browserCompatibleTabRequestSchema
|
||||
export const browserStopLoadingTabRequestSchema = browserCompatibleTabRequestSchema
|
||||
export const browserBackRequestSchema = browserBackTabRequestSchema
|
||||
export const browserReloadRequestSchema = browserReloadTabRequestSchema
|
||||
export const browserStopLoadingRequestSchema = browserStopLoadingTabRequestSchema
|
||||
export const browserSnapshotRequestSchema = browserCompatibleTabRequestSchema
|
||||
export const browserScreenshotRequestSchema = browserCompatibleTabRequestSchema
|
||||
|
||||
export const browserSetViewportRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema.optional(),
|
||||
tabId: browserTabIdSchema.optional(),
|
||||
leaseToken: browserViewportLeaseTokenSchema,
|
||||
bounds: z
|
||||
.object({
|
||||
x: z.number().int().nonnegative(),
|
||||
@@ -1429,7 +1478,9 @@ export const browserSetViewportRequestSchema = z
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => Boolean(value.conversationId) === Boolean(value.bounds),
|
||||
(value) =>
|
||||
(!value.conversationId && !value.tabId && !value.bounds) ||
|
||||
Boolean(value.conversationId && value.bounds),
|
||||
'浏览器视口参数不完整'
|
||||
)
|
||||
|
||||
@@ -1440,10 +1491,90 @@ export type BrowserViewportBounds = NonNullable<
|
||||
export const browserNavigateRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
tabId: browserTabIdSchema.optional(),
|
||||
url: z.string().min(1).max(8_192)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type BrowserNavigateRequest = Omit<
|
||||
z.infer<typeof browserNavigateRequestSchema>,
|
||||
'tabId'
|
||||
> & { tabId: BrowserTabId }
|
||||
|
||||
export const browserClickRequestSchema = browserCompatibleTabRequestSchema
|
||||
.extend({ ref: z.string().min(1).max(256) })
|
||||
.strict()
|
||||
export const browserTypeRequestSchema = browserCompatibleTabRequestSchema
|
||||
.extend({
|
||||
ref: z.string().min(1).max(256),
|
||||
text: z.string().max(16_384)
|
||||
})
|
||||
.strict()
|
||||
export const browserSelectRequestSchema = browserCompatibleTabRequestSchema
|
||||
.extend({
|
||||
ref: z.string().min(1).max(256),
|
||||
value: z.string().max(1_024)
|
||||
})
|
||||
.strict()
|
||||
|
||||
type BrowserOwnedRequest<T extends { tabId?: BrowserTabId }> = Omit<T, 'tabId'> & {
|
||||
tabId: BrowserTabId
|
||||
}
|
||||
|
||||
export type BrowserBackRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserBackTabRequestSchema>
|
||||
>
|
||||
export type BrowserReloadRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserReloadTabRequestSchema>
|
||||
>
|
||||
export type BrowserStopLoadingRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserStopLoadingTabRequestSchema>
|
||||
>
|
||||
export type BrowserSnapshotRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserSnapshotRequestSchema>
|
||||
>
|
||||
export type BrowserScreenshotRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserScreenshotRequestSchema>
|
||||
>
|
||||
export type BrowserClickRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserClickRequestSchema>
|
||||
>
|
||||
export type BrowserTypeRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserTypeRequestSchema>
|
||||
>
|
||||
export type BrowserSelectRequest = BrowserOwnedRequest<
|
||||
z.infer<typeof browserSelectRequestSchema>
|
||||
>
|
||||
export type BrowserSetViewportRequest =
|
||||
| {
|
||||
conversationId: string
|
||||
tabId: BrowserTabId
|
||||
bounds: BrowserViewportBounds
|
||||
leaseToken: string
|
||||
}
|
||||
| { leaseToken: string }
|
||||
|
||||
export type BrowserSnapshotResult = {
|
||||
url: string
|
||||
title: string
|
||||
nodes: Array<{
|
||||
ref: string
|
||||
role: string
|
||||
name: string
|
||||
value?: string
|
||||
disabled?: boolean
|
||||
focused?: boolean
|
||||
editable?: boolean
|
||||
}>
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type BrowserScreenshotResult = {
|
||||
type: 'image'
|
||||
mimeType: 'image/jpeg'
|
||||
data: string
|
||||
}
|
||||
|
||||
export const knowledgeIdSchema = z.string().uuid()
|
||||
export const knowledgeCreateSchema = z
|
||||
.object({
|
||||
@@ -1637,14 +1768,31 @@ export type DesktopApi = {
|
||||
onEvent: (listener: (event: AgentEvent) => void) => () => void
|
||||
}
|
||||
browser: {
|
||||
navigate: (conversationId: string, url: string) => Promise<void>
|
||||
back: (conversationId: string) => Promise<void>
|
||||
reload: (conversationId: string) => Promise<void>
|
||||
stopLoading: (conversationId: string) => Promise<void>
|
||||
setViewport: (
|
||||
conversationId?: string,
|
||||
bounds?: BrowserViewportBounds
|
||||
) => Promise<void>
|
||||
createTab?: (request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>
|
||||
listTabs?: (request: BrowserListTabsRequest) => Promise<BrowserTabSummary[]>
|
||||
closeTab?: (request: BrowserCloseTabRequest) => Promise<void>
|
||||
navigate: {
|
||||
(request: BrowserNavigateRequest): Promise<void>
|
||||
(conversationId: string, url: string): Promise<void>
|
||||
}
|
||||
back: {
|
||||
(request: BrowserBackRequest): Promise<void>
|
||||
(conversationId: string): Promise<void>
|
||||
}
|
||||
reload: {
|
||||
(request: BrowserReloadRequest): Promise<void>
|
||||
(conversationId: string): Promise<void>
|
||||
}
|
||||
stopLoading: {
|
||||
(request: BrowserStopLoadingRequest): Promise<void>
|
||||
(conversationId: string): Promise<void>
|
||||
}
|
||||
snapshot?: (request: BrowserSnapshotRequest) => Promise<BrowserSnapshotResult>
|
||||
click?: (request: BrowserClickRequest) => Promise<void>
|
||||
type?: (request: BrowserTypeRequest) => Promise<void>
|
||||
select?: (request: BrowserSelectRequest) => Promise<void>
|
||||
screenshot?: (request: BrowserScreenshotRequest) => Promise<BrowserScreenshotResult>
|
||||
setViewport: (request: BrowserSetViewportRequest) => Promise<void>
|
||||
stop: (conversationId: string) => Promise<void>
|
||||
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
||||
}
|
||||
|
||||
@@ -22,10 +22,18 @@ export const ipcChannels = {
|
||||
agentQuestionRespond: 'agent:question:respond',
|
||||
agentCompactConversation: 'agent:context:compact',
|
||||
agentEvent: 'agent:event',
|
||||
browserCreateTab: 'browser:tab:create',
|
||||
browserListTabs: 'browser:tab:list',
|
||||
browserCloseTab: 'browser:tab:close',
|
||||
browserNavigate: 'browser:navigate',
|
||||
browserBack: 'browser:back',
|
||||
browserReload: 'browser:reload',
|
||||
browserStopLoading: 'browser:stop-loading',
|
||||
browserSnapshot: 'browser:snapshot',
|
||||
browserClick: 'browser:click',
|
||||
browserType: 'browser:type',
|
||||
browserSelect: 'browser:select',
|
||||
browserScreenshot: 'browser:screenshot',
|
||||
browserSetViewport: 'browser:set-viewport',
|
||||
browserStop: 'browser:stop',
|
||||
browserState: 'browser:state',
|
||||
|
||||
@@ -10,7 +10,7 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion(
|
||||
z
|
||||
.object({
|
||||
provider: z.literal('model'),
|
||||
profileId: runtimeSelectionProfileIdSchema
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
@@ -48,17 +48,21 @@ export type RuntimeSelectionRepairSettings = {
|
||||
}>
|
||||
defaultModelProfileId: string
|
||||
opencodeModelSource:
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
continueModelSource:
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
deepseekHarnessModelSource?:
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
}
|
||||
|
||||
type RuntimeModelSource =
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
|
||||
@@ -79,34 +83,9 @@ type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][numbe
|
||||
|
||||
export function getRuntimeSelectionForProvider(
|
||||
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
|
||||
settings: RuntimeSelectionDefaultSettings
|
||||
_settings: RuntimeSelectionDefaultSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (provider === 'model') {
|
||||
return {
|
||||
provider,
|
||||
profileId: settings.defaultModelProfileId
|
||||
}
|
||||
}
|
||||
const source =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: provider === 'continue'
|
||||
? settings.continueModelSource
|
||||
: settings.deepseekHarnessModelSource
|
||||
const resolvedProfile =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelProfile
|
||||
: provider === 'continue'
|
||||
? settings.continueModelProfile
|
||||
: settings.deepseekHarnessModelProfile
|
||||
return {
|
||||
provider,
|
||||
...(source?.kind === 'profile'
|
||||
? { profileId: source.profileId }
|
||||
: !source && resolvedProfile
|
||||
? { profileId: resolvedProfile.id }
|
||||
: {})
|
||||
}
|
||||
return { provider }
|
||||
}
|
||||
|
||||
export function getDefaultRuntimeSelection(
|
||||
@@ -121,6 +100,20 @@ export function getDefaultRuntimeSelection(
|
||||
: getRuntimeSelectionForProvider('model', settings)
|
||||
}
|
||||
|
||||
// Resolve only for presentation or request diagnostics, never for persisted defaults.
|
||||
export function getRuntimeSelectionProfileId(
|
||||
selection: AgentRuntimeSelection,
|
||||
settings: RuntimeSelectionDefaultSettings
|
||||
): string | undefined {
|
||||
if (selection.provider === 'auto') return undefined
|
||||
if (selection.profileId) return selection.profileId
|
||||
if (selection.provider === 'model') return settings.defaultModelProfileId
|
||||
const source = selection.provider === 'opencode' ? settings.opencodeModelSource
|
||||
: selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource
|
||||
return source?.kind === 'default' ? settings.defaultModelProfileId
|
||||
: source?.kind === 'profile' ? source.profileId : undefined
|
||||
}
|
||||
|
||||
export function isChannelModelProfileUsable(
|
||||
profile: ChannelModelProfile
|
||||
): boolean {
|
||||
@@ -162,11 +155,7 @@ export function repairChannelRuntimeSelection(
|
||||
isChannelModelProfileUsable(profile)
|
||||
) ??
|
||||
settings.modelProfiles.find(isChannelModelProfileUsable)
|
||||
const defaultDirectSelection: AgentRuntimeSelection = {
|
||||
provider: 'model',
|
||||
profileId:
|
||||
defaultDirectProfile?.id ?? settings.defaultModelProfileId
|
||||
}
|
||||
const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' }
|
||||
if (selection.provider === 'auto') {
|
||||
return defaultDirectSelection
|
||||
}
|
||||
@@ -196,7 +185,7 @@ export function repairChannelRuntimeSelection(
|
||||
return repaired
|
||||
}
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === repaired.profileId
|
||||
(candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id)
|
||||
)
|
||||
return profile && isChannelModelProfileUsable(profile)
|
||||
? repaired
|
||||
@@ -217,29 +206,15 @@ export function repairAgentRuntimeSelection(
|
||||
return selection
|
||||
}
|
||||
if (selection.provider === 'model') {
|
||||
return {
|
||||
provider: 'model',
|
||||
profileId: settings.defaultModelProfileId
|
||||
}
|
||||
}
|
||||
const source =
|
||||
selection.provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: selection.provider === 'continue'
|
||||
? settings.continueModelSource
|
||||
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
|
||||
return {
|
||||
provider: selection.provider,
|
||||
...(source.kind === 'profile'
|
||||
? { profileId: source.profileId }
|
||||
: {})
|
||||
return { provider: 'model' }
|
||||
}
|
||||
return { provider: selection.provider }
|
||||
}
|
||||
|
||||
export function agentRuntimeSelectionKey(
|
||||
selection: AgentRuntimeSelection
|
||||
): string {
|
||||
return `${selection.provider}:${'profileId' in selection
|
||||
? selection.profileId ?? 'platform'
|
||||
? selection.profileId ?? 'default'
|
||||
: 'default'}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { workbarTargetRefSchema } from './workbar-contracts'
|
||||
import { workbarExecutionTargetRefSchema } from './workbar-contracts'
|
||||
|
||||
export const TERMINAL_LIMITS = {
|
||||
maximumSessionsPerWindow: 12,
|
||||
@@ -35,7 +35,7 @@ export type TerminalSessionId = z.infer<
|
||||
typeof terminalSessionIdSchema
|
||||
>
|
||||
|
||||
export const terminalTargetSchema = workbarTargetRefSchema
|
||||
export const terminalTargetSchema = workbarExecutionTargetRefSchema
|
||||
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
|
||||
|
||||
export const terminalSessionStateSchema = z.enum([
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
WORKBAR_LIMITS,
|
||||
normalizeWorkbarLayoutPreferences,
|
||||
workbarAppDefinitionSchema,
|
||||
workbarLayoutPreferencesSchema,
|
||||
workbarTabInstanceSchema
|
||||
@@ -14,20 +15,28 @@ const projectId = '00000000-0000-4000-8000-000000000201'
|
||||
describe('workbar contracts', () => {
|
||||
it('freezes the first application catalog and instance policies', () => {
|
||||
expect(
|
||||
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({
|
||||
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultContext, defaultOpen, required, closable, reorderable }) => ({
|
||||
id,
|
||||
instancePolicy,
|
||||
defaultOpen
|
||||
defaultContext,
|
||||
defaultOpen,
|
||||
required,
|
||||
closable,
|
||||
reorderable
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'tasks', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'workspace', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'browser', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'results', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
|
||||
{ id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
|
||||
{ id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true },
|
||||
{ id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true },
|
||||
{
|
||||
id: 'terminal',
|
||||
instancePolicy: 'multiple',
|
||||
defaultOpen: false
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: false,
|
||||
required: false,
|
||||
closable: true,
|
||||
reorderable: true
|
||||
}
|
||||
])
|
||||
for (const definition of WORKBAR_APP_DEFINITIONS) {
|
||||
@@ -51,12 +60,52 @@ describe('workbar contracts', () => {
|
||||
activeInstanceId: terminalId,
|
||||
expanded: true,
|
||||
dock: 'right',
|
||||
widthRatio: 0.35
|
||||
widthRatio: 0.35,
|
||||
taskScope: 'current-project'
|
||||
} as const
|
||||
|
||||
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
|
||||
})
|
||||
|
||||
it('persists a browser conversation binding without a runtime tab identifier', () => {
|
||||
expect(
|
||||
workbarTabInstanceSchema.parse({
|
||||
id: '00000000-0000-4000-8000-000000000103',
|
||||
appId: 'browser',
|
||||
title: '浏览器 · 会话 A',
|
||||
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
|
||||
})
|
||||
).toEqual({
|
||||
id: '00000000-0000-4000-8000-000000000103',
|
||||
appId: 'browser',
|
||||
title: '浏览器 · 会话 A',
|
||||
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
|
||||
})
|
||||
expect(
|
||||
workbarTabInstanceSchema.safeParse({
|
||||
id: '00000000-0000-4000-8000-000000000103',
|
||||
appId: 'browser',
|
||||
title: '浏览器',
|
||||
targetRef: { type: 'conversation', conversationId: 'conversation-a' },
|
||||
tabId: '00000000-0000-4000-8000-000000000999'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('explicitly keeps every existing app visible across context switches', () => {
|
||||
for (const definition of WORKBAR_APP_DEFINITIONS) {
|
||||
expect(definition.visibleAcrossContextSwitches).toBe(true)
|
||||
const { visibleAcrossContextSwitches, ...withoutVisibility } = definition
|
||||
expect(workbarAppDefinitionSchema.safeParse(withoutVisibility).success).toBe(false)
|
||||
expect(
|
||||
workbarAppDefinitionSchema.parse({
|
||||
...withoutVisibility,
|
||||
visibleAcrossContextSwitches
|
||||
}).visibleAcrossContextSwitches
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown and sensitive target parameters', () => {
|
||||
for (const targetRef of [
|
||||
{ type: 'local', cwd: 'C:\\secret' },
|
||||
@@ -102,7 +151,8 @@ describe('workbar contracts', () => {
|
||||
activeInstanceId: taskId,
|
||||
expanded: true,
|
||||
dock: 'right',
|
||||
widthRatio: 0.4
|
||||
widthRatio: 0.4,
|
||||
taskScope: 'current-project'
|
||||
}
|
||||
expect(
|
||||
workbarLayoutPreferencesSchema.safeParse({
|
||||
@@ -131,4 +181,61 @@ describe('workbar contracts', () => {
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes duplicate policies, fixed defaults, and stale active state', () => {
|
||||
const browserId = '00000000-0000-4000-8000-000000000103'
|
||||
const secondBrowserId = '00000000-0000-4000-8000-000000000104'
|
||||
const workspaceId = '00000000-0000-4000-8000-000000000105'
|
||||
const normalized = normalizeWorkbarLayoutPreferences(
|
||||
{
|
||||
instances: [
|
||||
{ id: browserId, appId: 'browser', title: '浏览器' },
|
||||
{ id: secondBrowserId, appId: 'browser', title: '浏览器 2' },
|
||||
{ id: workspaceId, appId: 'workspace', title: '工作区' },
|
||||
{ id: terminalId, appId: 'workspace', title: '重复工作区' }
|
||||
],
|
||||
activeInstanceId: terminalId,
|
||||
expanded: true,
|
||||
dock: 'right',
|
||||
widthRatio: 0.4,
|
||||
taskScope: 'all-projects'
|
||||
},
|
||||
[
|
||||
{ id: taskId, appId: 'tasks', title: '任务中心' },
|
||||
{ id: workspaceId, appId: 'workspace', title: '工作区' }
|
||||
]
|
||||
)
|
||||
|
||||
expect(normalized?.instances.map(({ id, appId }) => ({ id, appId })))
|
||||
.toEqual([
|
||||
{ id: taskId, appId: 'tasks' },
|
||||
{ id: browserId, appId: 'browser' },
|
||||
{ id: secondBrowserId, appId: 'browser' },
|
||||
{ id: workspaceId, appId: 'workspace' }
|
||||
])
|
||||
expect(normalized?.activeInstanceId).toBe(taskId)
|
||||
expect(normalized?.taskScope).toBe('all-projects')
|
||||
})
|
||||
|
||||
it('preserves a valid empty layout with a null active instance', () => {
|
||||
expect(
|
||||
normalizeWorkbarLayoutPreferences(
|
||||
{
|
||||
instances: [],
|
||||
activeInstanceId: null,
|
||||
expanded: false,
|
||||
dock: 'right',
|
||||
widthRatio: 0.4
|
||||
},
|
||||
[]
|
||||
)
|
||||
).toEqual({
|
||||
instances: [],
|
||||
activeInstanceId: null,
|
||||
expanded: false,
|
||||
dock: 'right',
|
||||
widthRatio: 0.4,
|
||||
taskScope: 'current-project'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,7 +38,8 @@ export type WorkbarInstancePolicy = z.infer<
|
||||
|
||||
export const workbarDefaultContextSchema = z.enum([
|
||||
'application',
|
||||
'current-project'
|
||||
'current-project',
|
||||
'current-conversation'
|
||||
])
|
||||
export type WorkbarDefaultContext = z.infer<
|
||||
typeof workbarDefaultContextSchema
|
||||
@@ -78,7 +79,11 @@ export const workbarAppDefinitionSchema = z
|
||||
),
|
||||
instancePolicy: workbarInstancePolicySchema,
|
||||
defaultContext: workbarDefaultContextSchema,
|
||||
visibleAcrossContextSwitches: z.boolean(),
|
||||
defaultOpen: z.boolean(),
|
||||
required: z.boolean(),
|
||||
closable: z.boolean(),
|
||||
reorderable: z.boolean(),
|
||||
availability: workbarAvailabilitySchema
|
||||
})
|
||||
.strict()
|
||||
@@ -93,57 +98,77 @@ export type WorkbarAppDefinition = z.infer<
|
||||
export const WORKBAR_APP_DEFINITIONS = [
|
||||
{
|
||||
id: 'tasks',
|
||||
visibleAcrossContextSwitches: true,
|
||||
label: '任务中心',
|
||||
icon: 'tasks',
|
||||
description: '查看和管理当前任务。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: true,
|
||||
required: true,
|
||||
closable: false,
|
||||
reorderable: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
visibleAcrossContextSwitches: true,
|
||||
label: '工作区',
|
||||
icon: 'workspace',
|
||||
description: '查看当前项目的工作区。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: true,
|
||||
required: true,
|
||||
closable: false,
|
||||
reorderable: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
visibleAcrossContextSwitches: true,
|
||||
label: '浏览器',
|
||||
icon: 'browser',
|
||||
description: '浏览任务相关内容。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'application',
|
||||
instancePolicy: 'multiple',
|
||||
defaultContext: 'current-conversation',
|
||||
defaultOpen: true,
|
||||
required: false,
|
||||
closable: true,
|
||||
reorderable: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
visibleAcrossContextSwitches: true,
|
||||
label: '成果',
|
||||
icon: 'results',
|
||||
description: '查看任务生成的成果。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: true,
|
||||
required: false,
|
||||
closable: true,
|
||||
reorderable: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
visibleAcrossContextSwitches: true,
|
||||
label: '终端',
|
||||
icon: 'terminal',
|
||||
description: '打开当前项目的用户终端。',
|
||||
instancePolicy: 'multiple',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: false,
|
||||
required: false,
|
||||
closable: true,
|
||||
reorderable: true,
|
||||
availability: { state: 'available' }
|
||||
}
|
||||
] as const satisfies readonly WorkbarAppDefinition[]
|
||||
|
||||
export const workbarTargetRefSchema = z.discriminatedUnion('type', [
|
||||
export const workbarExecutionTargetRefSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('local') }).strict(),
|
||||
z
|
||||
.object({
|
||||
@@ -152,6 +177,19 @@ export const workbarTargetRefSchema = z.discriminatedUnion('type', [
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
export type WorkbarExecutionTargetRef = z.infer<
|
||||
typeof workbarExecutionTargetRefSchema
|
||||
>
|
||||
|
||||
export const workbarTargetRefSchema = z.union([
|
||||
workbarExecutionTargetRefSchema,
|
||||
z
|
||||
.object({
|
||||
type: z.literal('conversation'),
|
||||
conversationId: z.string().min(1).max(128)
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
export type WorkbarTargetRef = z.infer<
|
||||
typeof workbarTargetRefSchema
|
||||
>
|
||||
@@ -165,13 +203,37 @@ export const workbarTabInstanceSchema = z
|
||||
})
|
||||
.strict()
|
||||
.superRefine((instance, context) => {
|
||||
if (instance.appId === 'terminal' && !instance.targetRef) {
|
||||
if (
|
||||
instance.appId === 'terminal' &&
|
||||
(!instance.targetRef || instance.targetRef.type === 'conversation')
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['targetRef'],
|
||||
message: 'Terminal instances require a public target reference'
|
||||
})
|
||||
}
|
||||
if (
|
||||
instance.appId === 'browser' &&
|
||||
instance.targetRef &&
|
||||
instance.targetRef.type !== 'conversation'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['targetRef'],
|
||||
message: 'Browser instances only accept conversation bindings'
|
||||
})
|
||||
}
|
||||
if (
|
||||
instance.appId !== 'browser' &&
|
||||
instance.targetRef?.type === 'conversation'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['targetRef'],
|
||||
message: 'Conversation bindings are reserved for browser instances'
|
||||
})
|
||||
}
|
||||
})
|
||||
export type WorkbarTabInstance = z.infer<
|
||||
typeof workbarTabInstanceSchema
|
||||
@@ -183,7 +245,14 @@ const singleInstanceAppIds = new Set<WorkbarAppId>(
|
||||
).map((definition) => definition.id)
|
||||
)
|
||||
|
||||
export const workbarLayoutPreferencesSchema = z
|
||||
export const workbarTaskScopeSchema = z.enum([
|
||||
'current-project',
|
||||
'global',
|
||||
'all-projects'
|
||||
])
|
||||
export type WorkbarTaskScope = z.infer<typeof workbarTaskScopeSchema>
|
||||
|
||||
const workbarLayoutShapeSchema = z
|
||||
.object({
|
||||
instances: z
|
||||
.array(workbarTabInstanceSchema)
|
||||
@@ -191,9 +260,12 @@ export const workbarLayoutPreferencesSchema = z
|
||||
activeInstanceId: z.string().uuid().nullable(),
|
||||
expanded: z.boolean(),
|
||||
dock: z.literal('right'),
|
||||
widthRatio: z.number().finite().gt(0).lt(1)
|
||||
widthRatio: z.number().finite().gt(0).lt(1),
|
||||
taskScope: workbarTaskScopeSchema.default('current-project')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema
|
||||
.superRefine((layout, context) => {
|
||||
const instanceIds = new Set<string>()
|
||||
const seenSingleApps = new Set<WorkbarAppId>()
|
||||
@@ -244,3 +316,77 @@ export const workbarLayoutPreferencesSchema = z
|
||||
export type WorkbarLayoutPreferences = z.infer<
|
||||
typeof workbarLayoutPreferencesSchema
|
||||
>
|
||||
|
||||
export function normalizeWorkbarLayoutPreferences(
|
||||
value: unknown,
|
||||
defaultInstances: readonly WorkbarTabInstance[]
|
||||
): WorkbarLayoutPreferences | undefined {
|
||||
const parsed = workbarLayoutShapeSchema.safeParse(value)
|
||||
if (!parsed.success) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const instanceIds = new Set<string>()
|
||||
const seenSingleApps = new Set<WorkbarAppId>()
|
||||
const instances: WorkbarTabInstance[] = []
|
||||
for (const instance of parsed.data.instances) {
|
||||
if (
|
||||
instanceIds.has(instance.id) ||
|
||||
(singleInstanceAppIds.has(instance.appId) &&
|
||||
seenSingleApps.has(instance.appId))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
instanceIds.add(instance.id)
|
||||
if (singleInstanceAppIds.has(instance.appId)) {
|
||||
seenSingleApps.add(instance.appId)
|
||||
}
|
||||
instances.push(instance)
|
||||
}
|
||||
|
||||
for (const defaultInstance of defaultInstances) {
|
||||
const definition = WORKBAR_APP_DEFINITIONS.find(
|
||||
(candidate) => candidate.id === defaultInstance.appId
|
||||
)
|
||||
if (
|
||||
definition?.required !== true ||
|
||||
instances.some(
|
||||
(instance) => instance.appId === defaultInstance.appId
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const collidingIdIndex = instances.findIndex(
|
||||
(instance) => instance.id === defaultInstance.id
|
||||
)
|
||||
if (collidingIdIndex >= 0) {
|
||||
const [removed] = instances.splice(collidingIdIndex, 1)
|
||||
if (removed && singleInstanceAppIds.has(removed.appId)) {
|
||||
seenSingleApps.delete(removed.appId)
|
||||
}
|
||||
}
|
||||
instances.splice(
|
||||
WORKBAR_APP_DEFINITIONS.findIndex(
|
||||
(candidate) => candidate.id === defaultInstance.appId
|
||||
),
|
||||
0,
|
||||
defaultInstance
|
||||
)
|
||||
}
|
||||
|
||||
const boundedInstances = instances.slice(
|
||||
0,
|
||||
WORKBAR_LIMITS.maximumOpenInstances
|
||||
)
|
||||
const activeInstanceId = boundedInstances.some(
|
||||
(instance) => instance.id === parsed.data.activeInstanceId
|
||||
)
|
||||
? parsed.data.activeInstanceId
|
||||
: boundedInstances[0]?.id ?? null
|
||||
|
||||
return workbarLayoutPreferencesSchema.parse({
|
||||
...parsed.data,
|
||||
instances: boundedInstances,
|
||||
activeInstanceId
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user