feat(coding): complete PI conversation UI
This commit is contained in:
@@ -103,12 +103,20 @@ const projectApi = vi.hoisted(() => ({
|
||||
config: vi.fn(),
|
||||
conversations: vi.fn(),
|
||||
create: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
}));
|
||||
const conversationApi = vi.hoisted(() => ({
|
||||
snapshot: vi.fn(),
|
||||
events: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
recover: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
model: vi.fn(),
|
||||
thinking: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
fork: vi.fn(),
|
||||
respond: vi.fn(),
|
||||
diagnostics: vi.fn(),
|
||||
}));
|
||||
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
|
||||
|
||||
@@ -117,6 +125,7 @@ vi.mock('@/lib/coding-projects', () => ({
|
||||
getCodingProjectConfig: projectApi.config,
|
||||
listCodingProjectConversations: projectApi.conversations,
|
||||
createCodingProjectConversation: projectApi.create,
|
||||
patchCodingProjectConversation: projectApi.patch,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-conversations', () => ({
|
||||
@@ -124,6 +133,13 @@ vi.mock('@/lib/coding-conversations', () => ({
|
||||
openCodingConversationEvents: conversationApi.events,
|
||||
submitCodingConversationPrompt: conversationApi.submit,
|
||||
recoverCodingConversation: conversationApi.recover,
|
||||
abortCodingConversation: conversationApi.abort,
|
||||
setCodingConversationModel: conversationApi.model,
|
||||
setCodingConversationThinking: conversationApi.thinking,
|
||||
compactCodingConversation: conversationApi.compact,
|
||||
forkCodingConversation: conversationApi.fork,
|
||||
respondCodingConversationInteraction: conversationApi.respond,
|
||||
getCodingRuntimeDiagnostics: conversationApi.diagnostics,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
|
||||
@@ -200,6 +216,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
'utf8',
|
||||
);
|
||||
expect(sourceText).not.toMatch(/(?:import|export)[\s\S]*?from\s+['"][^'"]*opencode/i);
|
||||
expect(sourceText).not.toMatch(/['"](?:share|unshare|unrevert|revert|todo|todos|global runtime)['"]/i);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -539,6 +556,8 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
preparing
|
||||
recovering={false}
|
||||
runStatus="preparing"
|
||||
mode="prompt"
|
||||
queue={{ items: [] }}
|
||||
error={null}
|
||||
recoverableError={false}
|
||||
acceptedCount={1}
|
||||
@@ -546,6 +565,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
submitting={false}
|
||||
placeholder="Message"
|
||||
onChange={vi.fn()}
|
||||
onModeChange={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
onRecover={vi.fn()}
|
||||
onAddFiles={vi.fn()}
|
||||
|
||||
@@ -159,4 +159,105 @@ describe('CodingConversationTimeline', () => {
|
||||
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders nested subagents, compacted retry state, inline tool output, and explicit branch wording', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-feature-ui', 1);
|
||||
const snapshot = {
|
||||
...base,
|
||||
nodes: [
|
||||
{
|
||||
kind: 'message' as const,
|
||||
id: 'message-feature',
|
||||
sourceEntryId: 'entry-feature',
|
||||
role: 'assistant' as const,
|
||||
status: 'complete' as const,
|
||||
blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }],
|
||||
},
|
||||
{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-feature',
|
||||
toolCallId: 'call-feature',
|
||||
toolName: 'read_file',
|
||||
title: '读取文件',
|
||||
inputText: 'src/app.ts',
|
||||
status: 'complete' as const,
|
||||
output: [{ kind: 'text' as const, id: 'tool-output', text: 'inline result', status: 'complete' as const }],
|
||||
details: { schema: 'changed-file.v1' as const, paths: ['src/app.ts'] },
|
||||
},
|
||||
{
|
||||
kind: 'subagent' as const,
|
||||
id: 'subagent-feature',
|
||||
runId: 'run-feature',
|
||||
details: {
|
||||
schema: 'subagent.v1' as const,
|
||||
dispatchId: 'dispatch-feature',
|
||||
mode: 'parallel' as const,
|
||||
tasks: [
|
||||
{ taskId: 'task-a', agentId: 'reader', toolProfile: 'read-only' as const, status: 'complete' as const, summary: 'Read complete' },
|
||||
{ taskId: 'task-b', agentId: 'builder', toolProfile: 'coding' as const, status: 'error' as const, errorCode: 'BUILD_FAILED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'subagent' as const,
|
||||
id: 'subagent-single',
|
||||
runId: 'run-feature',
|
||||
details: {
|
||||
schema: 'subagent.v1' as const,
|
||||
dispatchId: 'dispatch-single',
|
||||
mode: 'single' as const,
|
||||
tasks: [{ taskId: 'task-single', agentId: 'reviewer', toolProfile: 'read-only' as const, status: 'aborted' as const }],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'subagent' as const,
|
||||
id: 'subagent-chain',
|
||||
runId: 'run-feature',
|
||||
details: {
|
||||
schema: 'subagent.v1' as const,
|
||||
dispatchId: 'dispatch-chain',
|
||||
mode: 'chain' as const,
|
||||
tasks: [
|
||||
{ taskId: 'task-chain-a', agentId: 'planner', toolProfile: 'read-only' as const, status: 'complete' as const },
|
||||
{ taskId: 'task-chain-b', agentId: 'implementer', toolProfile: 'coding' as const, status: 'skipped' as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'compaction' as const,
|
||||
id: 'compact-feature',
|
||||
runId: 'run-feature',
|
||||
source: 'automatic' as const,
|
||||
status: 'error' as const,
|
||||
willRetry: true,
|
||||
summary: '保留了本轮关键上下文。',
|
||||
},
|
||||
],
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-feature-ui',
|
||||
workerGeneration: 1,
|
||||
seq: snapshot.cursor.seq,
|
||||
snapshot,
|
||||
});
|
||||
const onFork = vi.fn();
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' }));
|
||||
expect(onFork).toHaveBeenCalledWith('entry-feature');
|
||||
expect(screen.getByText('并行子任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('单个子任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('串行子任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('reader')).toBeInTheDocument();
|
||||
expect(screen.getByText('BUILD_FAILED')).toBeInTheDocument();
|
||||
expect(screen.getByText('系统会自动重试。')).toBeInTheDocument();
|
||||
expect(screen.getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull();
|
||||
expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,4 +79,62 @@ describe('coding Conversations Host facade', () => {
|
||||
'events:/api/coding/events?conversationId=conversation%2Fa',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes every PI-130 Conversation control through the product Host surface', async () => {
|
||||
const model = {
|
||||
model: { accountId: 'account/1', modelId: 'model/1', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
};
|
||||
const forked = { id: 'forked-1' };
|
||||
const diagnostics = { revision: { provider: 1, resources: 2 }, workers: [] };
|
||||
hostApi.fetch
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ model })
|
||||
.mockResolvedValueOnce({ model })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ conversation: forked })
|
||||
.mockResolvedValueOnce({ interactions: [{ id: 'interaction-1' }] })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ runtime: diagnostics });
|
||||
const facade = await import('@/lib/coding-conversations');
|
||||
|
||||
await facade.abortCodingConversation('conversation/a');
|
||||
await expect(facade.setCodingConversationModel('conversation/a', model.model)).resolves.toBe(model);
|
||||
await expect(facade.setCodingConversationThinking('conversation/a', 'high')).resolves.toBe(model);
|
||||
await facade.compactCodingConversation('conversation/a');
|
||||
await facade.recoverCodingConversation('conversation/a');
|
||||
await expect(facade.forkCodingConversation('conversation/a', 'entry/1')).resolves.toBe(forked);
|
||||
await facade.listCodingConversationInteractions('conversation/a');
|
||||
await facade.respondCodingConversationInteraction('conversation/a', {
|
||||
interactionId: 'interaction/1',
|
||||
optionId: 'option-1',
|
||||
});
|
||||
await expect(facade.getCodingRuntimeDiagnostics()).resolves.toBe(diagnostics);
|
||||
|
||||
expect(hostApi.fetch.mock.calls).toEqual([
|
||||
['/api/coding/conversations/conversation%2Fa/abort', { method: 'POST', body: '{}' }],
|
||||
['/api/coding/conversations/conversation%2Fa/model', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: model.model }),
|
||||
}],
|
||||
['/api/coding/conversations/conversation%2Fa/thinking', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ thinkingLevel: 'high' }),
|
||||
}],
|
||||
['/api/coding/conversations/conversation%2Fa/compact', { method: 'POST', body: '{}' }],
|
||||
['/api/coding/conversations/conversation%2Fa/recover', { method: 'POST', body: '{}' }],
|
||||
['/api/coding/conversations/conversation%2Fa/fork', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceEntryId: 'entry/1' }),
|
||||
}],
|
||||
['/api/coding/interactions?conversationId=conversation%2Fa'],
|
||||
['/api/coding/interactions/interaction%2F1/respond', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ conversationId: 'conversation/a', optionId: 'option-1' }),
|
||||
}],
|
||||
['/api/coding/runtime/diagnostics'],
|
||||
]);
|
||||
expect(JSON.stringify(hostApi.fetch.mock.calls)).not.toContain('/api/opencode');
|
||||
});
|
||||
});
|
||||
|
||||
220
tests/unit/coding-feature-ui.test.tsx
Normal file
220
tests/unit/coding-feature-ui.test.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
|
||||
|
||||
const interactionApi = vi.hoisted(() => ({
|
||||
respond: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
model: vi.fn(),
|
||||
thinking: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/coding-conversations', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/coding-conversations')>(),
|
||||
respondCodingConversationInteraction: interactionApi.respond,
|
||||
abortCodingConversation: interactionApi.abort,
|
||||
compactCodingConversation: interactionApi.compact,
|
||||
setCodingConversationModel: interactionApi.model,
|
||||
setCodingConversationThinking: interactionApi.thinking,
|
||||
}));
|
||||
|
||||
describe('PI-130 feature-complete Coding UI', () => {
|
||||
it('builds vendor-neutral Conversation model choices without runtime keys', async () => {
|
||||
const { buildCodingModelOptions, parseCodingModelKey } = await import(
|
||||
'@/lib/coding-model-options'
|
||||
);
|
||||
const account = {
|
||||
id: 'account-1',
|
||||
vendorId: 'custom',
|
||||
label: 'Work account',
|
||||
authMode: 'api_key',
|
||||
model: 'model-a',
|
||||
fallbackModels: ['model-b', 'model-a'],
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-08-24T00:00:00.000Z',
|
||||
updatedAt: '2026-08-24T00:00:00.000Z',
|
||||
} satisfies ProviderAccount;
|
||||
const vendor = { id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo;
|
||||
|
||||
const options = buildCodingModelOptions([account], [vendor]);
|
||||
|
||||
expect(options.map((option) => option.modelId)).toEqual(['model-a', 'model-b']);
|
||||
expect(options[0].label).toBe('Work account · Custom vendor / model-a');
|
||||
expect(parseCodingModelKey(options[0].key)).toEqual({ accountId: 'account-1', modelId: 'model-a' });
|
||||
expect(JSON.stringify(options)).not.toMatch(/opencode|providerID/i);
|
||||
});
|
||||
|
||||
it('exposes steer/follow-up modes and queue waiting state while a turn runs', async () => {
|
||||
const onModeChange = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||||
render(
|
||||
<CodingComposer
|
||||
value="Please add tests"
|
||||
editable
|
||||
canSend
|
||||
preparing={false}
|
||||
recovering={false}
|
||||
runStatus="running"
|
||||
mode="follow-up"
|
||||
queue={{ items: [{ id: 'queue-1', clientRequestId: 'request-1', mode: 'follow-up', text: 'Queued', attachmentIds: [] }] }}
|
||||
error={null}
|
||||
recoverableError={false}
|
||||
acceptedCount={0}
|
||||
attachments={[]}
|
||||
submitting={false}
|
||||
placeholder="Prompt"
|
||||
onChange={vi.fn()}
|
||||
onModeChange={onModeChange}
|
||||
onSubmit={onSubmit}
|
||||
onRecover={vi.fn()}
|
||||
onAddFiles={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('1 条消息正在等待;本轮结算后会继续处理。')).toBeInTheDocument();
|
||||
expect(screen.getByText('第 1 位 · 下一轮')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), { target: { value: 'steer' } });
|
||||
expect(onModeChange).toHaveBeenCalledWith('steer');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('answers product interactions and explains stale responses', async () => {
|
||||
interactionApi.respond.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('409 stale'));
|
||||
const { CodingInteractionPanel } = await import('@/pages/Chat/CodingInteractionPanel');
|
||||
const onSettled = vi.fn();
|
||||
const { rerender } = render(
|
||||
<CodingInteractionPanel
|
||||
conversationId="conversation-1"
|
||||
interactions={[{
|
||||
id: 'interaction-select',
|
||||
conversationId: 'conversation-1',
|
||||
runId: 'run-1',
|
||||
kind: 'select',
|
||||
title: '选择方案',
|
||||
options: [{ id: 'option-a', label: '方案 A' }],
|
||||
status: 'pending',
|
||||
}]}
|
||||
onSettled={onSettled}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '方案 A' }));
|
||||
await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', {
|
||||
interactionId: 'interaction-select',
|
||||
optionId: 'option-a',
|
||||
}));
|
||||
expect(onSettled).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(
|
||||
<CodingInteractionPanel
|
||||
conversationId="conversation-1"
|
||||
interactions={[{
|
||||
id: 'interaction-input',
|
||||
conversationId: 'conversation-1',
|
||||
runId: 'run-1',
|
||||
kind: 'input',
|
||||
title: '输入名称',
|
||||
status: 'pending',
|
||||
}]}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '输入名称的回答' }), { target: { value: 'Makelore' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
|
||||
expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps model, thinking, abort, metadata, and fork controls on the selected Conversation', async () => {
|
||||
const { useProviderStore } = await import('@/stores/providers');
|
||||
useProviderStore.setState({
|
||||
accounts: [{
|
||||
id: 'account-1',
|
||||
vendorId: 'custom',
|
||||
label: 'Work account',
|
||||
authMode: 'api_key',
|
||||
model: 'model-a',
|
||||
fallbackModels: ['model-b'],
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: '2026-08-24T00:00:00.000Z',
|
||||
updatedAt: '2026-08-24T00:00:00.000Z',
|
||||
}],
|
||||
vendors: [{ id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo],
|
||||
});
|
||||
interactionApi.abort.mockResolvedValue(undefined);
|
||||
interactionApi.model.mockResolvedValue({ model: null, modelResolution: 'required' });
|
||||
interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' });
|
||||
const callbacks = {
|
||||
rename: vi.fn(async () => undefined),
|
||||
archive: vi.fn(async () => undefined),
|
||||
unread: vi.fn(async () => undefined),
|
||||
fork: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
recover: vi.fn(async () => undefined),
|
||||
inspector: vi.fn(),
|
||||
};
|
||||
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||||
render(
|
||||
<CodingConversationHeader
|
||||
conversation={{
|
||||
id: 'conversation-1',
|
||||
agentId: 'agent-1',
|
||||
title: 'Feature UI',
|
||||
archivedAt: null,
|
||||
unread: false,
|
||||
createdAt: '2026-08-24T00:00:00.000Z',
|
||||
updatedAt: '2026-08-24T00:00:00.000Z',
|
||||
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
}}
|
||||
snapshot={{
|
||||
schemaVersion: 1,
|
||||
conversation: {
|
||||
id: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
title: 'Feature UI',
|
||||
model: {
|
||||
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
},
|
||||
},
|
||||
nodes: [],
|
||||
run: { status: 'running', runId: 'run-1', mode: 'prompt' },
|
||||
queue: { items: [] },
|
||||
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq: 1 },
|
||||
}}
|
||||
connectionState="live"
|
||||
onRename={callbacks.rename}
|
||||
onArchive={callbacks.archive}
|
||||
onToggleUnread={callbacks.unread}
|
||||
onFork={callbacks.fork}
|
||||
onRefresh={callbacks.refresh}
|
||||
onRecover={callbacks.recover}
|
||||
onOpenInspector={callbacks.inspector}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '当前对话模型' }), {
|
||||
target: { value: JSON.stringify(['account-1', 'model-b']) },
|
||||
});
|
||||
await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', {
|
||||
accountId: 'account-1',
|
||||
modelId: 'model-b',
|
||||
thinkingLevel: 'medium',
|
||||
}));
|
||||
await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce());
|
||||
fireEvent.click(screen.getByRole('button', { name: '中止' }));
|
||||
await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-1'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建分支' }));
|
||||
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
|
||||
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,16 @@ describe('coding project Host facade', () => {
|
||||
.mockResolvedValueOnce({ projects: [], activeProjectId: null })
|
||||
.mockResolvedValueOnce({ snapshot: { project: { id: 'project/a' }, config: {} } })
|
||||
.mockResolvedValueOnce({ conversations: [] })
|
||||
.mockResolvedValueOnce({ conversation: { id: 'conversation-1' } });
|
||||
.mockResolvedValueOnce({ conversation: { id: 'conversation-1' } })
|
||||
.mockResolvedValueOnce({ conversation: { id: 'conversation-1', title: 'Renamed' } })
|
||||
.mockResolvedValueOnce({});
|
||||
const {
|
||||
createCodingProjectConversation,
|
||||
getCodingProjectConfig,
|
||||
listCodingProjectConversations,
|
||||
listCodingProjects,
|
||||
patchCodingProjectConversation,
|
||||
deleteCodingProjectConversation,
|
||||
} = await import('@/lib/coding-projects');
|
||||
|
||||
await listCodingProjects();
|
||||
@@ -28,6 +32,8 @@ describe('coding project Host facade', () => {
|
||||
agentId: 'agent-1',
|
||||
title: '新对话',
|
||||
});
|
||||
await patchCodingProjectConversation('conversation/1', { title: 'Renamed', unread: true });
|
||||
await deleteCodingProjectConversation('conversation/1');
|
||||
|
||||
expect(hostApiFetch.mock.calls).toEqual([
|
||||
['/api/coding/projects'],
|
||||
@@ -37,6 +43,11 @@ describe('coding project Host facade', () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectId: 'project/a', agentId: 'agent-1', title: '新对话' }),
|
||||
}],
|
||||
['/api/coding/conversations/conversation%2F1', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Renamed', unread: true }),
|
||||
}],
|
||||
['/api/coding/conversations/conversation%2F1', { method: 'DELETE' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,4 +121,39 @@ describe('coding workspace store', () => {
|
||||
expect(store.getState().conversations).toEqual([created]);
|
||||
expect(store.getState().creatingAgentIds).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps title, archive, and unread metadata scoped to the patched Conversation', async () => {
|
||||
const first = conversation('conversation-a', 'agent-a');
|
||||
const second = conversation('conversation-b', 'agent-a');
|
||||
const patchConversation = vi.fn(async (_id: string, patch: { title?: string; archived?: boolean; unread?: boolean }) => ({
|
||||
...first,
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
...(patch.archived ? { archivedAt: '2026-08-24T01:00:00.000Z' } : {}),
|
||||
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
|
||||
}));
|
||||
const store = createCodingWorkspaceStore({
|
||||
listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })),
|
||||
getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })),
|
||||
listConversations: vi.fn(async () => [first, second]),
|
||||
createConversation: vi.fn(),
|
||||
patchConversation,
|
||||
});
|
||||
await store.getState().load();
|
||||
|
||||
await store.getState().patchConversation(first.id, {
|
||||
title: 'Feature UI',
|
||||
archived: true,
|
||||
unread: true,
|
||||
});
|
||||
|
||||
expect(patchConversation).toHaveBeenCalledWith(first.id, {
|
||||
title: 'Feature UI',
|
||||
archived: true,
|
||||
unread: true,
|
||||
});
|
||||
expect(store.getState().conversations).toEqual([
|
||||
expect.objectContaining({ id: first.id, title: 'Feature UI', unread: true, archivedAt: expect.any(String) }),
|
||||
second,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user