feat(trial): assemble completed Agent workspace updates

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 12:11:56 +08:00
1 parent 8289f27f3e
commit 7c51bb4a00
38 files changed
+2081 -276

No files matched your search

+153
View File
@@ -44,6 +44,141 @@ function moduleFor(fetchImpl: typeof fetch) {
beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); });
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('creation failure recovery', () => {
const validation = (field = 'purpose', type = 'string_too_short') => ({ detail: [{
loc: ['body', field], type, msg: 'private upstream text', input: 'private input', ctx: { secret: 'private context' },
}] });
it('clears a first rejected creation and explains the empty-introduction service mismatch safely', async () => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422));
const module = moduleFor(transport);
await expect(module.create({ ...input, purpose: '' })).rejects.toMatchObject({
status: 422, code: 'introduction_required', commandOutcome: 'definitive_failure',
message: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试',
});
expect((await module.recovery()).pending).toEqual([]);
expect(JSON.parse(transport.mock.calls[1][1].body).purpose).toBe('');
});
it('does not diagnose an empty introduction when a nonempty introduction is rejected as too short', async () => {
const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)));
await expect(module.create(input)).rejects.toMatchObject({ code: 'introduction_too_short', commandOutcome: 'definitive_failure' });
expect((await module.recovery()).pending).toEqual([]);
});
it.each([
['name', 'string_too_long', 'invalid_name'], ['purpose', 'string_too_long', 'invalid_introduction'],
['icon', 'string_type', 'invalid_icon'], ['private-field', 'missing', 'invalid_input'],
])('maps request validation for %s to a fixed product message', async (field, type, code) => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(field, type), 422));
const module = moduleFor(transport);
const error = await module.create(input).catch(failure => failure);
expect(error).toMatchObject({ code, commandOutcome: 'definitive_failure' });
expect(error.message).not.toMatch(/private|secret|upstream/u);
expect((await module.recovery()).pending).toEqual([]);
});
it.each([
[400, 'invalid_input'], [422, 'invalid_input'], [401, 'session_expired'],
[403, 'module_unavailable'], [423, 'account_locked'],
])('clears a fresh creation only on a recognized rejection (%s %s)', async (status, code) => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code } }, status));
const module = moduleFor(transport);
await expect(module.create(input)).rejects.toMatchObject({ code, commandOutcome: 'definitive_failure' });
expect((await module.recovery()).pending).toEqual([]);
});
it.each([408, 409, 429, 500, 502])('keeps ambiguous creation status %s recoverable with its original input', async status => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code: 'invalid_input' } }, status));
const module = moduleFor(transport);
await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' });
expect((await module.recovery()).pending).toMatchObject([{ operation: 'createAgent', input }]);
});
it.each([
new Response('not JSON', { status: 422 }),
json({ detail: [{ loc: ['response', 'purpose'], type: 'string_too_short', msg: 'private' }] }, 422),
json({ detail: [{ loc: ['body', 'purpose'], type: 'unrecognized-validation' }] }, 422),
json({ detail: { code: 'unknown-error', message: 'private' } }, 422),
json({ ...draft, slug: 'invalid-response-slug' }),
])('keeps malformed errors or successful responses pending rather than allowing another creation', async response => {
const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(response));
await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' });
expect((await module.recovery()).pending).toHaveLength(1);
});
it('preserves an older uncertain creation after a later validation rejection, then recovers success with the same ID', async () => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
.mockResolvedValueOnce(json(validation(), 422)).mockResolvedValueOnce(json({ ...draft, purpose: '' }));
const module = moduleFor(transport);
const original = { ...input, purpose: '' };
await expect(module.create(original)).rejects.toMatchObject({ commandOutcome: 'unknown' });
const pending = (await module.recovery()).pending[0];
await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ commandOutcome: 'unknown' });
expect((await module.recovery()).pending).toEqual([pending]);
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ result: { slug: draft.slug } });
expect((await module.recovery()).pending).toEqual([]);
expect(transport.mock.calls.slice(1).map(call => JSON.parse(call[1].body))).toEqual([original, original, original]);
});
it('does not treat local validation as proof an older operation failed', async () => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'));
const module = moduleFor(transport);
await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' });
await expect(module.create({ ...input, name: '' })).rejects.toMatchObject({ commandOutcome: 'unknown' });
expect((await module.recovery()).pending).toHaveLength(1);
expect(transport).toHaveBeenCalledTimes(2);
});
it('shares simultaneous identical creates and rejects changed input without a second POST', async () => {
let finish!: (response: Response) => void;
const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve; }));
const module = moduleFor(transport);
const first = module.create(input);
const second = module.create(input);
const firstRejected = expect(first).rejects.toMatchObject({ commandOutcome: 'definitive_failure' });
const secondRejected = expect(second).rejects.toMatchObject({ commandOutcome: 'definitive_failure' });
await expect(module.create({ ...input, name: 'changed input' })).rejects.toMatchObject({ code: 'operation_conflict', commandOutcome: 'unknown' });
await vi.waitFor(() => expect(finish).toBeDefined());
finish(json(validation(), 422));
await Promise.all([firstRejected, secondRejected]);
expect(transport).toHaveBeenCalledTimes(2);
expect((await module.recovery()).pending).toEqual([]);
});
it('keeps the original account record when a rejection arrives after an account switch', async () => {
let finish!: (response: Response) => void;
const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve; }));
const module = moduleFor(transport);
const rejected = expect(module.create(input)).rejects.toMatchObject({ code: 'account_changed', commandOutcome: 'unknown' });
await vi.waitFor(() => expect(finish).toBeDefined());
const pending = (await module.recovery()).pending[0];
login('b'.repeat(64));
finish(json(validation(), 422));
await rejected;
expect((await module.recovery()).pending).toEqual([]);
expect(Array.from(journalRecords.values()).some(state => state.pending.some(item => item.id === pending.id))).toBe(true);
});
it('preserves recovery behavior for other operations after a validation rejection', async () => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422));
const module = moduleFor(transport);
await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' });
expect((await module.recovery()).pending).toHaveLength(1);
});
it('does not mark a rejected retry of another operation as a confirmed failure', async () => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
.mockResolvedValueOnce(json(validation(), 422));
const module = moduleFor(transport);
await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } }))
.rejects.toMatchObject({ commandOutcome: 'unknown' });
const pending = (await module.recovery()).pending[0];
await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' });
expect((await module.recovery()).pending).toEqual([pending]);
});
});
it.each(['', ' '])('preserves an empty introduction through create, save and list (%j)', async purpose => {
const emptyDraft = { ...draft, purpose: '' };
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(emptyDraft))
@@ -65,6 +200,24 @@ it.each([null, 42, 'x'.repeat(2001)])('rejects malformed or overlong introductio
});
describe('Main cloud Agents boundary', () => {
it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => {
const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1',
extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' },
display_name: '上网查资料', display_description: '在网上查找相关信息。',
status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] };
const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1',
tool_calls: [{ id: 'call-1', name: 'search', display_name: '上网查资料', display_description: '在网上查找相关信息。',
args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] };
for (const input of [
{ operation: 'history', input: { thread_id: 'thread-1' } },
{ operation: 'channelConversation', input: { session_id: 'session-1' } },
]) {
expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] });
expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden');
expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata');
}
});
it.each(Array.from({ length: 16 }, (_, index) => `avatar-${String(index + 1).padStart(2, '0')}`))('uploads the bundled platform portrait %s through scoped Main transport without a dialog', async id => {
const icon = '/api/storage/public/images/creator/preset.png';
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ icon, private: 'hidden' }));