Adds a singleton, ephemeral 'settings' pane kind to the workspace. Opened via a new bottom-pinned button in ProjectSidebar (emits an open_settings_pane event when a session is mounted; navigates to /settings otherwise). Pane has three sections — Session, Project, Theme — and a maximize toggle that hides sibling pane columns via display:none on desktop only. Settings panes don't count toward MAX_PANES and are filtered out of the localStorage persistence layer so reload always restores a clean workspace. Schema (additive): - projects.default_system_prompt TEXT NOT NULL DEFAULT '' - projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false - sessions.web_search_enabled BOOLEAN (nullable; null = inherit) Inference resolves user_prompt = session.system_prompt.trim() || project.default_system_prompt.trim() — empty/whitespace at either layer means "no override". Keeps the columns NOT NULL and matches the existing inherit semantics. Server routes: - GET /api/projects/:id (new; settings pane refetches on project_updated) - PATCH /api/projects/:id accepts default_system_prompt, default_web_search_enabled - PATCH /api/sessions/:id accepts web_search_enabled (tri-state) - POST /api/projects/:id/sessions/archive-all + GET /api/projects/:id/sessions/open-count - POST /api/sessions/:id/chats/archive-all + GET /api/sessions/:id/chats/open-count - PATCH /api/sessions/:id now broadcasts session_updated on every successful PATCH (was rename-only). Lets SettingsPane open in another tab pick up edits without a refetch. Bulk-archive publishes one session_archived / chat_archived frame per affected id so useSidebar's existing reducer cases handle them incrementally — no new frame type, no payload widening. ModelPicker refactored: shared ModelList inside a responsive shell. Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu button + BottomSheet. Header in Session.tsx drops the pill wrap on mobile since the new trigger is the visual. ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker when sessionId + webSearchEnabled props are provided. One item for now — Web search — with a checkmark reflecting the stored value (true), not the effective one. Click PATCHes the override; to restore inherit-from-project the user opens SettingsPane. ThemePicker lifted out of pages/Settings.tsx into a reusable component. The standalone /settings route is now a thin wrapper that mounts <ThemePicker /> with a Back button on top (navigate(-1) with fallback to '/'); the SettingsPane Theme tab renders the same picker bare. Project section delete-flow removed (button + confirm dialog + handler). Replaced with "Archive all sessions" using the same two-step count → confirm → fire pattern as "Archive all chats" in the Session section. api.projects.remove() stays in the client because useProjects.ts still uses it. Hand-rolled Switch primitive in SettingsPane (no shadcn switch in the project; spec said no new deps). Section nav is plain buttons (no shadcn Tabs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
8.6 KiB
TypeScript
243 lines
8.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { buildMessagesPayload } from '../inference.js';
|
|
import type {
|
|
Message,
|
|
MessageRole,
|
|
Project,
|
|
Session,
|
|
ToolCall,
|
|
ToolResult,
|
|
} from '../../types/api.js';
|
|
|
|
// ---- fixtures ---------------------------------------------------------------
|
|
|
|
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id: 'sess',
|
|
project_id: 'proj',
|
|
name: 'test session',
|
|
model: 'test-model',
|
|
system_prompt: '',
|
|
status: 'open',
|
|
created_at: new Date(0).toISOString(),
|
|
updated_at: new Date(0).toISOString(),
|
|
agent_id: null,
|
|
web_search_enabled: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
return {
|
|
id: 'proj',
|
|
name: 'test project',
|
|
path: '/tmp/proj',
|
|
added_at: new Date(0).toISOString(),
|
|
last_session_id: null,
|
|
status: 'open',
|
|
gitea_remote: null,
|
|
default_system_prompt: '',
|
|
default_web_search_enabled: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
let counter = 0;
|
|
function makeMessage(
|
|
role: MessageRole,
|
|
content: string,
|
|
overrides: Partial<Message> = {}
|
|
): Message {
|
|
counter += 1;
|
|
return {
|
|
id: `m${counter}`,
|
|
session_id: 'sess',
|
|
chat_id: 'chat',
|
|
role,
|
|
content,
|
|
kind: 'message',
|
|
tool_calls: null,
|
|
tool_results: null,
|
|
status: 'complete',
|
|
last_seq: 0,
|
|
tokens_used: null,
|
|
ctx_used: null,
|
|
ctx_max: null,
|
|
started_at: null,
|
|
finished_at: null,
|
|
created_at: new Date(counter * 1000).toISOString(),
|
|
metadata: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---- tests ------------------------------------------------------------------
|
|
|
|
describe('buildMessagesPayload', () => {
|
|
it('prepends a system prompt containing the project path', () => {
|
|
const session = makeSession();
|
|
const project = makeProject({ path: '/tmp/my-proj' });
|
|
const result = buildMessagesPayload(session, project, []);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]!.role).toBe('system');
|
|
expect(result[0]!.content).toContain('/tmp/my-proj');
|
|
});
|
|
|
|
it('appends session.system_prompt to the system message when set', () => {
|
|
const session = makeSession({ system_prompt: 'Be terse.' });
|
|
const project = makeProject();
|
|
const result = buildMessagesPayload(session, project, []);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]!.role).toBe('system');
|
|
expect(result[0]!.content).toContain('Be terse.');
|
|
});
|
|
|
|
it('returns user/assistant messages in order when no compact marker is present', () => {
|
|
const session = makeSession();
|
|
const project = makeProject();
|
|
const history: Message[] = [
|
|
makeMessage('user', 'hi'),
|
|
makeMessage('assistant', 'hello'),
|
|
makeMessage('user', 'how are you'),
|
|
makeMessage('assistant', 'great'),
|
|
];
|
|
const result = buildMessagesPayload(session, project, history);
|
|
// 1 system + 4 history messages
|
|
expect(result).toHaveLength(5);
|
|
expect(result[0]!.role).toBe('system');
|
|
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
|
|
expect(result[3]).toMatchObject({ role: 'user', content: 'how are you' });
|
|
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
|
|
});
|
|
|
|
it('starts from the latest compact marker, emitting it as a system message', () => {
|
|
const session = makeSession();
|
|
const project = makeProject();
|
|
const history: Message[] = [
|
|
makeMessage('user', 'old1'),
|
|
makeMessage('assistant', 'oldreply1'),
|
|
makeMessage('user', 'old2'),
|
|
makeMessage('assistant', 'compacted summary text', { kind: 'compact' }),
|
|
makeMessage('user', 'new1'),
|
|
makeMessage('assistant', 'newreply1'),
|
|
];
|
|
const result = buildMessagesPayload(session, project, history);
|
|
// Expect: leading base-system prompt, then the compact as system, then
|
|
// the user/assistant pair following it.
|
|
expect(result).toHaveLength(4);
|
|
expect(result[0]!.role).toBe('system');
|
|
expect(result[1]).toMatchObject({
|
|
role: 'system',
|
|
content: 'compacted summary text',
|
|
});
|
|
expect(result[2]).toMatchObject({ role: 'user', content: 'new1' });
|
|
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
|
|
});
|
|
|
|
it('uses only the most recent compact when multiple are present', () => {
|
|
const session = makeSession();
|
|
const project = makeProject();
|
|
const history: Message[] = [
|
|
makeMessage('user', 'u1'),
|
|
makeMessage('assistant', 'first compact summary', { kind: 'compact' }),
|
|
makeMessage('user', 'u2'),
|
|
makeMessage('assistant', 'second compact summary', { kind: 'compact' }),
|
|
makeMessage('user', 'u3'),
|
|
makeMessage('assistant', 'final reply'),
|
|
];
|
|
const result = buildMessagesPayload(session, project, history);
|
|
// Expect: base system + latest compact as system + the two messages
|
|
// following it. The earlier compact and pre-compact history are dropped.
|
|
expect(result).toHaveLength(4);
|
|
expect(result[0]!.role).toBe('system');
|
|
expect(result[1]).toMatchObject({
|
|
role: 'system',
|
|
content: 'second compact summary',
|
|
});
|
|
expect(result[2]).toMatchObject({ role: 'user', content: 'u3' });
|
|
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final reply' });
|
|
// None of the earlier content should leak through
|
|
const concatenated = result.map((m) => m.content ?? '').join(' ');
|
|
expect(concatenated).not.toContain('first compact summary');
|
|
expect(concatenated).not.toContain('u1');
|
|
expect(concatenated).not.toContain('u2');
|
|
});
|
|
|
|
it('skips streaming and cancelled assistant rows', () => {
|
|
const session = makeSession();
|
|
const project = makeProject();
|
|
const history: Message[] = [
|
|
makeMessage('user', 'hi'),
|
|
makeMessage('assistant', 'partial...', { status: 'streaming' }),
|
|
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
|
|
makeMessage('assistant', 'final answer'),
|
|
];
|
|
const result = buildMessagesPayload(session, project, history);
|
|
// 1 system + 1 user + 1 assistant (only the complete one)
|
|
expect(result).toHaveLength(3);
|
|
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
|
});
|
|
|
|
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
|
|
const session = makeSession();
|
|
const project = makeProject();
|
|
const toolCall: ToolCall = {
|
|
id: 'call_abc',
|
|
name: 'view_file',
|
|
args: { path: 'src/index.ts' },
|
|
};
|
|
const toolResult: ToolResult = {
|
|
tool_call_id: 'call_abc',
|
|
output: { contents: 'console.log(1)' },
|
|
truncated: false,
|
|
};
|
|
const history: Message[] = [
|
|
makeMessage('user', 'show me the file'),
|
|
makeMessage('assistant', '', { tool_calls: [toolCall] }),
|
|
makeMessage('tool', '', { tool_results: toolResult }),
|
|
makeMessage('assistant', 'here it is'),
|
|
];
|
|
const result = buildMessagesPayload(session, project, history);
|
|
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
|
|
expect(result).toHaveLength(5);
|
|
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
|
|
expect(result[2]!.role).toBe('assistant');
|
|
expect(result[2]!.tool_calls).toBeDefined();
|
|
expect(result[2]!.tool_calls).toHaveLength(1);
|
|
expect(result[2]!.tool_calls![0]).toMatchObject({
|
|
id: 'call_abc',
|
|
type: 'function',
|
|
function: { name: 'view_file' },
|
|
});
|
|
// The OpenAI shape stringifies args.
|
|
expect(result[2]!.tool_calls![0]!.function.arguments).toBe(
|
|
JSON.stringify({ path: 'src/index.ts' })
|
|
);
|
|
// assistant with empty content should be serialized as content: null
|
|
expect(result[2]!.content).toBeNull();
|
|
expect(result[3]).toMatchObject({
|
|
role: 'tool',
|
|
tool_call_id: 'call_abc',
|
|
});
|
|
// Non-string tool output is JSON-stringified.
|
|
expect(result[3]!.content).toBe(JSON.stringify({ contents: 'console.log(1)' }));
|
|
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
|
});
|
|
|
|
it('skips tool rows with no tool_results', () => {
|
|
const session = makeSession();
|
|
const project = makeProject();
|
|
const history: Message[] = [
|
|
makeMessage('user', 'do it'),
|
|
makeMessage('tool', '', { tool_results: null }),
|
|
makeMessage('assistant', 'done'),
|
|
];
|
|
const result = buildMessagesPayload(session, project, history);
|
|
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
|
|
expect(result).toHaveLength(3);
|
|
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
|
|
});
|
|
});
|