Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d887b81
feat(playground): add agent configuration backend
samuv Apr 24, 2026
f84d5b2
refactor(chat): extract reusable ModelPicker from ModelSelector
samuv Apr 24, 2026
02802bf
feat(playground): add agents management pages
samuv Apr 24, 2026
30abf4a
feat(playground): wire agent selection into chat settings and sidebar
samuv Apr 24, 2026
c2a5f5c
refactor(skills): split install dialog into Name and Version fields
samuv Apr 24, 2026
0310b80
feat(playground): render Skill built card for build_skill tool output
samuv Apr 24, 2026
0b2bd7c
fix(playground): register flattened agent detail and edit routes
samuv Apr 24, 2026
53e7f98
fix(chat): route Skill built 'View details' to a real build tag
samuv Apr 24, 2026
4bd2e16
fix(skills): leave install modal Version empty when build has no version
samuv Apr 24, 2026
1b23778
feat(playground): drop the Planner built-in agent
samuv Apr 24, 2026
1709284
chore(playground): drop unused agent exports flagged by knip
samuv Apr 24, 2026
be08bed
refactor: put the feature behind feature flag
samuv Apr 27, 2026
02f8064
fix(playground): invalidate canonical chat thread query key when assi…
samuv Apr 27, 2026
65da270
refactor(playground): use Button for sidebar Agents and New chat entries
samuv Apr 27, 2026
d4628f7
test(playground): cover SkillBuildResultCard, build-result parser, an…
samuv Apr 27, 2026
12e18f2
fix(playground): clear main-process active thread id when navigating …
samuv Apr 27, 2026
5eb5793
test: add more coverage on the new components
samuv Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions main/src/app-events/__tests__/when-ready.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ vi.mock('electron-store', () => ({
vi.mock('../../db/database')
vi.mock('../../db/migrator')
vi.mock('../../db/reconcile-from-store')
vi.mock('../../chat/agents/registry', () => ({
seedBuiltinAgents: vi.fn(),
}))
vi.mock('../../auto-update')
vi.mock('../../cli')
vi.mock('../../app-state')
Expand Down
2 changes: 2 additions & 0 deletions main/src/app-events/when-ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { app, nativeTheme, session } from 'electron'
import { getDb } from '../db/database'
import { runMigrations } from '../db/migrator'
import { reconcileFromStore } from '../db/reconcile-from-store'
import { seedBuiltinAgents } from '../chat/agents/registry'
import { resetUpdateState, initAutoUpdate } from '../auto-update'
import { validateCliAlignment, handleValidationResult } from '../cli'
import { setCliValidationResult, getTray } from '../app-state'
Expand All @@ -25,6 +26,7 @@ export function register() {
getDb()
runMigrations()
reconcileFromStore()
seedBuiltinAgents()
} catch (err) {
log.error('[DB] Database initialization failed:', err)
}
Expand Down
251 changes: 251 additions & 0 deletions main/src/chat/__tests__/streaming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockAgentStream = vi.hoisted(() => vi.fn())
const mockToolLoopAgentCtor = vi.hoisted(() => vi.fn())
const mockConvertToModelMessages = vi.hoisted(() =>
vi.fn().mockResolvedValue([])
)
const mockStepCountIs = vi.hoisted(() => vi.fn(() => 'step-count-marker'))
const mockCreateIdGenerator = vi.hoisted(() => vi.fn(() => () => 'msg_1'))

const mockCreateModelFromRequest = vi.hoisted(() =>
vi.fn(() => ({ id: 'model' }))
)
const mockCreateMcpTools = vi.hoisted(() =>
vi.fn().mockResolvedValue({ tools: {}, clients: [], enabledTools: {} })
)
const mockGetCachedUiMetadata = vi.hoisted(() => vi.fn(() => ({})))
const mockCreateBuiltinAgentTools = vi.hoisted(() =>
vi.fn(() => ({ tools: {}, cleanup: vi.fn() }))
)
const mockStreamUIMessagesOverIPC = vi.hoisted(() => vi.fn())
const mockUpdateThreadMessages = vi.hoisted(() =>
vi.fn(() => ({ success: true }))
)
const mockGetAgent = vi.hoisted(() => vi.fn())
const mockResolveAgentForThread = vi.hoisted(() => vi.fn())

vi.mock('ai', () => ({
ToolLoopAgent: class {
constructor(...args: unknown[]) {
mockToolLoopAgentCtor(...args)
}
stream = (...args: unknown[]) => mockAgentStream(...args)
},
stepCountIs: mockStepCountIs,
convertToModelMessages: mockConvertToModelMessages,
createIdGenerator: mockCreateIdGenerator,
}))

vi.mock('@sentry/electron/main', () => ({
startSpanManual: vi.fn(
async (
_opts: unknown,
fn: (span: unknown, finish: () => void) => unknown
) =>
fn(
{
spanContext: () => ({}),
setStatus: vi.fn(),
setAttribute: vi.fn(),
setAttributes: vi.fn(),
},
vi.fn()
)
),
startSpan: vi.fn(
(_opts: unknown, fn: (span: { addLink: () => void }) => unknown) =>
fn({ addLink: vi.fn() })
),
}))

vi.mock('../../logger', () => ({
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}))

vi.mock('../providers', () => ({
CHAT_PROVIDERS: [{ id: 'openai', name: 'OpenAI' }],
}))

vi.mock('../mcp-tools', () => ({
createMcpTools: mockCreateMcpTools,
getCachedUiMetadata: mockGetCachedUiMetadata,
}))

vi.mock('../stream-utils', () => ({
streamUIMessagesOverIPC: mockStreamUIMessagesOverIPC,
}))

vi.mock('../threads-storage', () => ({
updateThreadMessages: mockUpdateThreadMessages,
}))

vi.mock('../utils', () => ({
createModelFromRequest: mockCreateModelFromRequest,
}))

vi.mock('../agents/registry', () => ({
getAgent: mockGetAgent,
resolveAgentForThread: mockResolveAgentForThread,
}))

vi.mock('../agents/builtin-agent-tools', () => ({
createBuiltinAgentTools: mockCreateBuiltinAgentTools,
}))

import { handleChatStreamRealtime } from '../streaming'
import type { ChatRequest } from '../types'

const fakeSender = { send: vi.fn() } as unknown as Electron.WebContents

function makeRequest(overrides: Partial<ChatRequest> = {}): ChatRequest {
return {
chatId: 'thread-1',
messages: [],
model: 'gpt-4o',
provider: 'openai',
apiKey: 'sk-test',
...overrides,
} as ChatRequest
}

function fakeAgent(
id: string,
instructions: string,
builtinToolsKey: 'skills' | null = null
) {
return {
id,
kind: 'builtin' as const,
name: id,
description: '',
instructions,
builtinToolsKey,
createdAt: 0,
updatedAt: 0,
}
}

beforeEach(() => {
vi.clearAllMocks()
mockAgentStream.mockResolvedValue({
toUIMessageStream: vi.fn().mockReturnValue({}),
})
mockCreateMcpTools.mockResolvedValue({
tools: {},
clients: [],
enabledTools: {},
})
mockCreateBuiltinAgentTools.mockReturnValue({
tools: {},
cleanup: vi.fn(),
})
})

describe('handleChatStreamRealtime — agent resolution', () => {
it('builds a ToolLoopAgent using instructions from the agent matching request.agentId', async () => {
mockGetAgent.mockReturnValue(
fakeAgent('custom.my-agent', 'CUSTOM AGENT INSTRUCTIONS')
)

await handleChatStreamRealtime(
makeRequest({ agentId: 'custom.my-agent' }),
'stream-1',
fakeSender
)

expect(mockGetAgent).toHaveBeenCalledWith('custom.my-agent')
expect(mockResolveAgentForThread).not.toHaveBeenCalled()
expect(mockToolLoopAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
instructions: 'CUSTOM AGENT INSTRUCTIONS',
})
)
})

it('falls back to resolveAgentForThread when no agentId is provided', async () => {
mockResolveAgentForThread.mockReturnValue(
fakeAgent('builtin.toolhive-assistant', 'DEFAULT INSTRUCTIONS')
)

await handleChatStreamRealtime(
makeRequest({ agentId: undefined }),
'stream-2',
fakeSender
)

expect(mockResolveAgentForThread).toHaveBeenCalledWith('thread-1')
expect(mockGetAgent).not.toHaveBeenCalled()
expect(mockToolLoopAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
instructions: 'DEFAULT INSTRUCTIONS',
})
)
})

it('falls back to resolveAgentForThread when the requested agentId does not exist', async () => {
mockGetAgent.mockReturnValue(null)
mockResolveAgentForThread.mockReturnValue(
fakeAgent('builtin.toolhive-assistant', 'FALLBACK INSTRUCTIONS')
)

await handleChatStreamRealtime(
makeRequest({ agentId: 'custom.deleted' }),
'stream-3',
fakeSender
)

expect(mockGetAgent).toHaveBeenCalledWith('custom.deleted')
expect(mockResolveAgentForThread).toHaveBeenCalledWith('thread-1')
expect(mockToolLoopAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
instructions: 'FALLBACK INSTRUCTIONS',
})
)
})

it('attaches built-in tools for the agent based on builtinToolsKey', async () => {
mockGetAgent.mockReturnValue(
fakeAgent('builtin.skills', 'SKILLS INSTRUCTIONS', 'skills')
)
const cleanup = vi.fn()
const skillsTools = { build_skill: { description: 'x' } }
mockCreateBuiltinAgentTools.mockReturnValue({
tools: skillsTools,
cleanup,
})

await handleChatStreamRealtime(
makeRequest({ agentId: 'builtin.skills' }),
'stream-4',
fakeSender
)

expect(mockCreateBuiltinAgentTools).toHaveBeenCalledWith('skills')
expect(mockToolLoopAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({ build_skill: expect.anything() }),
toolChoice: 'auto',
})
)
})

it('omits tools/toolChoice when neither MCP nor built-in tools are present', async () => {
mockGetAgent.mockReturnValue(
fakeAgent('builtin.toolhive-assistant', 'TOOLHIVE INSTRUCTIONS')
)

await handleChatStreamRealtime(
makeRequest({ agentId: 'builtin.toolhive-assistant' }),
'stream-5',
fakeSender
)

const ctorArg = mockToolLoopAgentCtor.mock.calls[0]![0] as {
tools?: unknown
toolChoice?: unknown
}
expect(ctorArg.tools).toBeUndefined()
expect(ctorArg.toolChoice).toBeUndefined()
})
})
Loading
Loading