Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions src/main/ai/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ describe('streamed tool calls', () => {
expect(calls).toHaveLength(2)
expect(calls.map(call => call.function.arguments)).toEqual([args, args])
})
it('accepts up to the tool call limit in one response', async () => {
const calls = await readAiStream(
stream([
...Array.from({ length: AI_LIMITS.toolCalls }, (_, index) =>
toolDelta(index, args, true)),
toolFinish,
]),
() => {},
)
expect(calls).toHaveLength(AI_LIMITS.toolCalls)
})
it('rejects calls without a successful finish and limits tool bytes', async () => {
await expect(
readAiStream(
Expand Down
105 changes: 104 additions & 1 deletion src/main/ai/__tests__/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,92 @@ it('openrouter keeps unsigned reasoning when the answer has no tool call', async
expect(result.calls).toEqual([])
expect(JSON.stringify(result.replay)).toContain('Думаю дальше')
})
function openRouterToolCalls(count: number) {
const chunk = (delta: object, finish_reason: string | null = null) => ({
id: 'msg1',
model: 'test',
created: 1,
choices: [{ index: 0, delta, finish_reason }],
})
const wire = [
chunk({ role: 'assistant', content: 'Секретный ответ' }),
...Array.from({ length: count }, (_, index) =>
chunk({
tool_calls: [
{
index,
id: `call${index}`,
type: 'function',
function: { name: 'read_http_context', arguments: '{}' },
},
],
})),
chunk({}, 'tool_calls'),
]
.map(event => `data: ${JSON.stringify(event)}\n\n`)
.join('')
return new Response(`${wire}data: [DONE]\n\n`, {
headers: { 'content-type': 'text/event-stream' },
})
}
it('openrouter accepts more than eight tool calls in one response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => openRouterToolCalls(9)),
)
const result = await generateAiResponse(
{
provider: 'openrouter',
baseURL: AI_DEFAULT_URLS.openrouter,
model: 'test',
apiKey: 'synthetic',
},
{
instructions: 'test',
messages: [{ role: 'user', content: 'hello' }],
tools: [tool],
signal: new AbortController().signal,
onDelta: () => {},
operation: 'test',
},
)
expect(result.calls).toHaveLength(9)
})
it('openrouter rejects too many tool calls with a content-free diagnostic', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => openRouterToolCalls(17)),
)
const event = vi.fn()
await expect(
generateAiResponse(
{
provider: 'openrouter',
baseURL: AI_DEFAULT_URLS.openrouter,
model: 'test',
apiKey: 'synthetic',
trace: { nextSpan: () => 1, event },
},
{
instructions: 'test',
messages: [{ role: 'user', content: 'hello' }],
tools: [tool],
signal: new AbortController().signal,
onDelta: () => {},
operation: 'test',
},
),
).rejects.toMatchObject({
code: 'invalidResponse',
diagnostic: 'toolCalls:17',
})
const failure = event.mock.calls.find(([name]) => name === 'request.error')
expect(failure?.[1]).toMatchObject({
code: 'invalidResponse',
diagnostic: 'toolCalls:17',
})
expect(JSON.stringify(failure)).not.toContain('Секретный ответ')
})
it.each(providers)(
'%s maps exhausted credits to the quota error',
async (provider) => {
Expand Down Expand Up @@ -565,10 +651,27 @@ it.each(providers)(
operation: 'test',
},
),
).rejects.toMatchObject({ code: 'invalidResponse' })
).rejects.toMatchObject({
code: 'invalidResponse',
diagnostic: expect.stringMatching(/^(finish:|emptyResponse)/),
})
},
)

it('accepts assistant messages with more than eight tool calls', () => {
const message = (count: number) => ({
role: 'assistant',
content: '',
tool_calls: Array.from({ length: count }, (_, index) => ({
id: `call${index}`,
type: 'function',
function: { name: 'read_http_context', arguments: '{}' },
})),
})
expect(aiMessageSchema.safeParse(message(9)).success).toBe(true)
expect(aiMessageSchema.safeParse(message(17)).success).toBe(false)
})

it('replays malformed arguments with their failure result instead of crashing JSON parsing', () => {
const message = aiMessageSchema.parse({
role: 'assistant',
Expand Down
91 changes: 89 additions & 2 deletions src/main/ai/__tests__/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ beforeEach(() => {
properties: {},
folderId: null,
isDeleted: 0,
isFavorites: 0,
},
]
state.folders[space] = [{ id: 3, name: 'Folder', parentId: null }]
Expand Down Expand Up @@ -489,9 +490,95 @@ it('paginates within a selected folder and excludes trash', () => {
})
expect(last.items).toHaveLength(4)
expect(last.nextOffset).toBeNull()
expect(workspaceInventory({ space: 'http', folderId: null }).items).toEqual(
[],
expect(
workspaceInventory({ space: 'http', folderId: 'inbox' }).items,
).toEqual([])
})

it('filters inventory by folder, inbox or all folders', () => {
state.records.code = [1, 2, null].map((folderId, index) => ({
id: index + 1,
name: `Snippet ${index}`,
folder: folderId === null ? null : { id: folderId, name: 'Folder' },
isDeleted: 0,
}))
const ids = (input: object) =>
workspaceInventory({ space: 'code', ...input }).items.map(
item => item.id,
)
expect(ids({ folderId: null })).toEqual([1, 2, 3])
expect(ids({})).toEqual([1, 2, 3])
expect(ids({ folderId: 'inbox' })).toEqual([3])
expect(ids({ folderId: 1 })).toEqual([1])
expect(
workspaceInventory({ space: 'code', folderId: 'inbox' }).items[0].folderId,
).toBeNull()
})

it('rejects task filters outside Notes with a recoverable hint', () => {
expect(() => workspaceInventory({ space: 'code', taskType: 'task' })).toThrow(
'TASK_FILTER_NOTES_ONLY',
)
expect(() =>
workspaceInventory({ space: 'http', taskStatus: 'todo' }),
).toThrow('TASK_FILTER_NOTES_ONLY')
expect(workspaceToolError(new Error('TASK_FILTER_NOTES_ONLY'))).toMatchObject(
{
error: 'TASK_FILTER_NOTES_ONLY',
hint: expect.stringContaining('Notes'),
},
)
state.records.http!.push({
...state.records.http![0],
id: 4,
name: 'Favorite',
isFavorites: 1,
})
expect(
workspaceInventory({ space: 'http', isFavorites: false }).items.map(
item => item.id,
),
).toEqual([1])
})

it('treats null inventory filters as no filter', () => {
const unfiltered = {
status: 'active',
isFavorites: null,
taskType: null,
taskStatus: null,
offset: 0,
}
const ids = (input: object) =>
workspaceInventory(input).items.map(item => item.id)
expect(ids({ space: 'code', ...unfiltered })).toEqual([1])
expect(ids({ space: 'http', ...unfiltered })).toEqual([1])
state.records.http!.push({
...state.records.http![0],
id: 4,
name: 'Favorite',
isFavorites: 1,
})
expect(ids({ space: 'http', isFavorites: null })).toEqual([1, 4])
expect(ids({ space: 'http', isFavorites: false })).toEqual([1])
state.records.notes!.push({
...state.records.notes![0],
id: 5,
name: 'Task',
properties: { type: 'task', status: 'todo' },
})
expect(
ids({
space: 'notes',
taskType: 'task',
taskStatus: null,
isFavorites: null,
}),
).toEqual([5])
expect(ids({ space: 'notes', ...unfiltered, folderId: null })).toEqual([
1,
5,
])
})

it('redacts secret fields from model reads and rejects placeholders in writes', () => {
Expand Down
8 changes: 6 additions & 2 deletions src/main/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ const chunkSchema = z.object({
tool_calls: z
.array(
z.object({
index: z.number().int().min(0).max(7),
index: z
.number()
.int()
.min(0)
.max(AI_LIMITS.toolCalls - 1),
id: z.string().optional(),
type: z.literal('function').optional(),
function: z
Expand All @@ -61,7 +65,7 @@ const chunkSchema = z.object({
.optional(),
}),
)
.max(8)
.max(AI_LIMITS.toolCalls)
.optional(),
content: z.string().nullable().optional(),
refusal: z.string().nullable().optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/main/ai/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function buildAiInstructions(toolNames: string[], remaining?: number) {
}
if (toolNames.includes('read_current_workspace')) {
sections.push(
'Use read_current_workspace to resolve the captured selection, folder and library. Selection is a list of IDs, not a copy of every item. Inventory supports taskType/taskStatus/isFavorites and returns properties. A workspace review holds at most 30 operations: finish each reviewed batch and report any remainder explicitly. Notes tags can be created or renamed with kind tag; Code tags have no rename operation. Code folders support defaultLanguage.',
'Use read_current_workspace to resolve the captured selection, folder and library. Selection is a list of IDs, not a copy of every item. Inventory returns properties; pass isFavorites, and for Notes only taskType/taskStatus, only when the user explicitly requested that filter. A workspace review holds at most 30 operations: finish each reviewed batch and report any remainder explicitly. Notes tags can be created or renamed with kind tag; Code tags have no rename operation. Code folders support defaultLanguage.',
)
}
if (toolNames.includes('perform_native_action')) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/ai/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export async function readResponsesStream(
: [],
)
if (
calls.length > 8
calls.length > AI_LIMITS.toolCalls
|| new Set(calls.map(call => call.id)).size !== calls.length
) {
throw new AiError('invalidResponse')
Expand Down
Loading