@openchambery/web 1.19.13-beta.3 → 1.19.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AssistantView-CKmIS1cS.js +2 -0
- package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-BdI4PT2H.js} +1 -1
- package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-2ewbRZE2.js} +4 -1
- package/dist/assets/MarkstreamRendererImpl-DXewO7S_.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bfs4CTUh.js → MobileDetailNavigation-BGaAyuBH.js} +1 -1
- package/dist/assets/{MobileShareBridge-DtwVLkBL.js → MobileShareBridge-BAZoyeIP.js} +1 -1
- package/dist/assets/MobileSurface-BVb3xNWt.js +2 -0
- package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-C8ukIdLA.js} +1 -1
- package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-CkQ61pbr.js} +1 -1
- package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-CSePZ_V8.js} +1 -1
- package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-BmSIrZ81.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-2BYaWLo1.js} +1 -1
- package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-Dk4gBaOu.js} +1 -1
- package/dist/assets/{gitApi-D1mvv14J.js → gitApi-DtPf-h-O.js} +1 -1
- package/dist/assets/{insertionBoundaries-BBn8YW82.js → insertionBoundaries-BMhzPFft.js} +1 -1
- package/dist/assets/{main-Cd7NwXx5.js → main-BjeOhdcG.js} +4 -4
- package/dist/assets/{main-phpzFgMN.js → main-Ci3JmtpW.js} +2 -2
- package/dist/assets/{miniChat-D6UYe50H.js → miniChat-BYrjYEXJ.js} +2 -2
- package/dist/assets/{mobile-w2Ed9I5O.js → mobile-BVMNnPK2.js} +2 -2
- package/dist/assets/{multirun-CYuxJPBX.js → multirun-CmaTeZON.js} +1 -1
- package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-cw9nrwpr.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-COwia8Cs.js} +2 -2
- package/dist/assets/{renderMobileApp-BvIE86_O.js → renderMobileApp-CXwwpY1t.js} +4 -4
- package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-DI8l0VyZ.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-lF5S7U4I.js → runtimeEndpointReset-BkAQXmXJ.js} +1 -1
- package/dist/assets/{sessionLookup-CNRDupYq.js → sessionLookup-Imc995rW.js} +1 -1
- package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-BDFUND4v.js} +5 -5
- package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-C5hZD1co.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-e9HjIqGz.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DANrWekj.js → useMobileNavigationStore-CYbDxRTM.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-BGy9_AhN.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-DTGE0CDs.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +20 -11
- package/server/lib/assistants/assign.js +56 -1
- package/server/lib/assistants/assign.test.js +60 -0
- package/server/lib/assistants/contact-tools.js +212 -1
- package/server/lib/assistants/contact-tools.test.js +151 -0
- package/server/lib/assistants/harness.js +56 -5
- package/server/lib/assistants/harness.test.js +17 -9
- package/server/lib/assistants/service.js +514 -16
- package/server/lib/assistants/service.test.js +546 -26
- package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
- package/dist/assets/MarkdownRenderer-v7n_D6nL.js +0 -2
- package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
- package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
-
import { createAssistantsService } from './service.js';
|
|
6
|
+
import { assignedSessionResumeMessageID, createAssistantsService } from './service.js';
|
|
7
7
|
import { assistantContractFixtures } from './contracts.js';
|
|
8
8
|
import {
|
|
9
9
|
CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
@@ -1815,6 +1815,218 @@ describe('assistants service', () => {
|
|
|
1815
1815
|
service.close();
|
|
1816
1816
|
});
|
|
1817
1817
|
|
|
1818
|
+
const sessionFollowCatalog = {
|
|
1819
|
+
provider: {
|
|
1820
|
+
list: async () => ({ data: { connected: ['xai', 'openai', 'p'] } }),
|
|
1821
|
+
},
|
|
1822
|
+
config: {
|
|
1823
|
+
providers: async () => ({
|
|
1824
|
+
data: {
|
|
1825
|
+
providers: [
|
|
1826
|
+
{
|
|
1827
|
+
id: 'xai',
|
|
1828
|
+
name: 'xAI',
|
|
1829
|
+
models: {
|
|
1830
|
+
'grok-4.6': {
|
|
1831
|
+
id: 'grok-4.6',
|
|
1832
|
+
name: 'Grok 4.6',
|
|
1833
|
+
modalities: { input: ['text', 'image'] },
|
|
1834
|
+
},
|
|
1835
|
+
},
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
id: 'openai',
|
|
1839
|
+
name: 'OpenAI',
|
|
1840
|
+
models: {
|
|
1841
|
+
'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
|
|
1842
|
+
},
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
id: 'p',
|
|
1846
|
+
name: 'P',
|
|
1847
|
+
models: { m: { id: 'm', name: 'M' } },
|
|
1848
|
+
},
|
|
1849
|
+
],
|
|
1850
|
+
},
|
|
1851
|
+
}),
|
|
1852
|
+
},
|
|
1853
|
+
};
|
|
1854
|
+
|
|
1855
|
+
it('reused sessionID without explicit model follows session.model when in catalog', async () => {
|
|
1856
|
+
const directory = root();
|
|
1857
|
+
const project = path.join(directory, 'app');
|
|
1858
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1859
|
+
const prompts = [];
|
|
1860
|
+
const gets = [];
|
|
1861
|
+
const service = setup(directory, {
|
|
1862
|
+
create: async () => ({ data: { id: 'ses_should_not_create' } }),
|
|
1863
|
+
get: async (input) => {
|
|
1864
|
+
gets.push(input);
|
|
1865
|
+
return { data: { id: input.sessionID, model: { providerID: 'xai', id: 'grok-4.6' } } };
|
|
1866
|
+
},
|
|
1867
|
+
promptAsync: async (input) => {
|
|
1868
|
+
prompts.push(input);
|
|
1869
|
+
return { response: { status: 204 } };
|
|
1870
|
+
},
|
|
1871
|
+
...sessionFollowCatalog,
|
|
1872
|
+
}, {
|
|
1873
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1874
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1875
|
+
const result = await assign.execute('call_1', {
|
|
1876
|
+
prompt: userText,
|
|
1877
|
+
sessionID: 'ses_reuse',
|
|
1878
|
+
projectPath: project,
|
|
1879
|
+
});
|
|
1880
|
+
return {
|
|
1881
|
+
text: result.content[0].text,
|
|
1882
|
+
bubbles: [result.content[0].text],
|
|
1883
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1884
|
+
};
|
|
1885
|
+
},
|
|
1886
|
+
});
|
|
1887
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1888
|
+
await settleSend(service, assistant.id, {
|
|
1889
|
+
messageID: 'client_assign_session_model',
|
|
1890
|
+
parts: [{ type: 'text', text: '继续修' }],
|
|
1891
|
+
});
|
|
1892
|
+
expect(gets.some((item) => item.sessionID === 'ses_reuse')).toBe(true);
|
|
1893
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1894
|
+
sessionID: 'ses_reuse',
|
|
1895
|
+
model: { providerID: 'xai', modelID: 'grok-4.6' },
|
|
1896
|
+
})]);
|
|
1897
|
+
expect(service.snapshot().assistants[0]).toMatchObject({ providerID: 'p', modelID: 'm' });
|
|
1898
|
+
service.close();
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
it('reused session falls back to assistant model when session model is not in catalog', async () => {
|
|
1902
|
+
const directory = root();
|
|
1903
|
+
const project = path.join(directory, 'app');
|
|
1904
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1905
|
+
const prompts = [];
|
|
1906
|
+
const service = setup(directory, {
|
|
1907
|
+
create: async () => ({ data: { id: 'ses_no' } }),
|
|
1908
|
+
get: async (input) => ({
|
|
1909
|
+
data: { id: input.sessionID, model: { providerID: 'missing', modelID: 'gone' } },
|
|
1910
|
+
}),
|
|
1911
|
+
promptAsync: async (input) => {
|
|
1912
|
+
prompts.push(input);
|
|
1913
|
+
return { response: { status: 204 } };
|
|
1914
|
+
},
|
|
1915
|
+
...sessionFollowCatalog,
|
|
1916
|
+
}, {
|
|
1917
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1918
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1919
|
+
const result = await assign.execute('call_1', {
|
|
1920
|
+
prompt: userText,
|
|
1921
|
+
sessionID: 'ses_stale',
|
|
1922
|
+
projectPath: project,
|
|
1923
|
+
});
|
|
1924
|
+
return {
|
|
1925
|
+
text: result.content[0].text,
|
|
1926
|
+
bubbles: [result.content[0].text],
|
|
1927
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1928
|
+
};
|
|
1929
|
+
},
|
|
1930
|
+
});
|
|
1931
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1932
|
+
await settleSend(service, assistant.id, {
|
|
1933
|
+
messageID: 'client_assign_session_stale',
|
|
1934
|
+
parts: [{ type: 'text', text: '继续' }],
|
|
1935
|
+
});
|
|
1936
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1937
|
+
sessionID: 'ses_stale',
|
|
1938
|
+
model: { providerID: 'p', modelID: 'm' },
|
|
1939
|
+
})]);
|
|
1940
|
+
service.close();
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
it('reused session falls back to assistant model when session.get fails', async () => {
|
|
1944
|
+
const directory = root();
|
|
1945
|
+
const project = path.join(directory, 'app');
|
|
1946
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1947
|
+
const prompts = [];
|
|
1948
|
+
const service = setup(directory, {
|
|
1949
|
+
create: async () => ({ data: { id: 'ses_no' } }),
|
|
1950
|
+
get: async () => {
|
|
1951
|
+
throw new Error('session get failed');
|
|
1952
|
+
},
|
|
1953
|
+
promptAsync: async (input) => {
|
|
1954
|
+
prompts.push(input);
|
|
1955
|
+
return { response: { status: 204 } };
|
|
1956
|
+
},
|
|
1957
|
+
...sessionFollowCatalog,
|
|
1958
|
+
}, {
|
|
1959
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1960
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1961
|
+
const result = await assign.execute('call_1', {
|
|
1962
|
+
prompt: userText,
|
|
1963
|
+
sessionID: 'ses_get_fail',
|
|
1964
|
+
projectPath: project,
|
|
1965
|
+
});
|
|
1966
|
+
expect(result.details.error).toBeUndefined();
|
|
1967
|
+
return {
|
|
1968
|
+
text: result.content[0].text,
|
|
1969
|
+
bubbles: [result.content[0].text],
|
|
1970
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1971
|
+
};
|
|
1972
|
+
},
|
|
1973
|
+
});
|
|
1974
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1975
|
+
const sent = await settleSend(service, assistant.id, {
|
|
1976
|
+
messageID: 'client_assign_session_get_fail',
|
|
1977
|
+
parts: [{ type: 'text', text: '继续' }],
|
|
1978
|
+
});
|
|
1979
|
+
expect(sent.settled.status).toBe('complete');
|
|
1980
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1981
|
+
sessionID: 'ses_get_fail',
|
|
1982
|
+
model: { providerID: 'p', modelID: 'm' },
|
|
1983
|
+
})]);
|
|
1984
|
+
service.close();
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
it('explicit model still overrides reused session model', async () => {
|
|
1988
|
+
const directory = root();
|
|
1989
|
+
const project = path.join(directory, 'app');
|
|
1990
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1991
|
+
const prompts = [];
|
|
1992
|
+
const service = setup(directory, {
|
|
1993
|
+
create: async () => ({ data: { id: 'ses_no' } }),
|
|
1994
|
+
get: async (input) => ({
|
|
1995
|
+
data: { id: input.sessionID, model: { providerID: 'xai', id: 'grok-4.6' } },
|
|
1996
|
+
}),
|
|
1997
|
+
promptAsync: async (input) => {
|
|
1998
|
+
prompts.push(input);
|
|
1999
|
+
return { response: { status: 204 } };
|
|
2000
|
+
},
|
|
2001
|
+
...sessionFollowCatalog,
|
|
2002
|
+
}, {
|
|
2003
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
2004
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
2005
|
+
const result = await assign.execute('call_1', {
|
|
2006
|
+
prompt: userText,
|
|
2007
|
+
sessionID: 'ses_explicit',
|
|
2008
|
+
projectPath: project,
|
|
2009
|
+
model: 'openai/gpt-4o',
|
|
2010
|
+
});
|
|
2011
|
+
return {
|
|
2012
|
+
text: result.content[0].text,
|
|
2013
|
+
bubbles: [result.content[0].text],
|
|
2014
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
2015
|
+
};
|
|
2016
|
+
},
|
|
2017
|
+
});
|
|
2018
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2019
|
+
await settleSend(service, assistant.id, {
|
|
2020
|
+
messageID: 'client_assign_session_explicit',
|
|
2021
|
+
parts: [{ type: 'text', text: '用 gpt' }],
|
|
2022
|
+
});
|
|
2023
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
2024
|
+
sessionID: 'ses_explicit',
|
|
2025
|
+
model: { providerID: 'openai', modelID: 'gpt-4o' },
|
|
2026
|
+
})]);
|
|
2027
|
+
service.close();
|
|
2028
|
+
});
|
|
2029
|
+
|
|
1818
2030
|
it('creates another assistant from create_assistant and persists the assistant card', async () => {
|
|
1819
2031
|
const directory = root();
|
|
1820
2032
|
const service = setup(directory, {}, {
|
|
@@ -1848,6 +2060,177 @@ describe('assistants service', () => {
|
|
|
1848
2060
|
service.close();
|
|
1849
2061
|
});
|
|
1850
2062
|
|
|
2063
|
+
it('reads and persists defaultPrompt via get_assistant_settings / update_default_prompt', async () => {
|
|
2064
|
+
const directory = root();
|
|
2065
|
+
const tips = [];
|
|
2066
|
+
const harnessTurns = [];
|
|
2067
|
+
let phase = 'read';
|
|
2068
|
+
const service = setup(directory, {}, {
|
|
2069
|
+
onRevisionTip: (tip) => tips.push(tip),
|
|
2070
|
+
runContactTurn: async ({ tools, assistant }) => {
|
|
2071
|
+
harnessTurns.push({
|
|
2072
|
+
phase,
|
|
2073
|
+
defaultPrompt: assistant.defaultPrompt,
|
|
2074
|
+
toolNames: tools.map((tool) => tool.name),
|
|
2075
|
+
});
|
|
2076
|
+
if (phase === 'read') {
|
|
2077
|
+
const get = tools.find((tool) => tool.name === 'get_assistant_settings');
|
|
2078
|
+
const result = await get.execute('call_get', {});
|
|
2079
|
+
expect(result.details.settings).toMatchObject({
|
|
2080
|
+
id: assistant.id,
|
|
2081
|
+
name: 'A',
|
|
2082
|
+
defaultPrompt: 'Base persona',
|
|
2083
|
+
providerID: 'p',
|
|
2084
|
+
modelID: 'm',
|
|
2085
|
+
enabled: true,
|
|
2086
|
+
});
|
|
2087
|
+
expect(result.content[0].text).toContain('Base persona');
|
|
2088
|
+
phase = 'write';
|
|
2089
|
+
return { text: 'Current default prompt loaded.', bubbles: ['Current default prompt loaded.'] };
|
|
2090
|
+
}
|
|
2091
|
+
if (phase === 'write') {
|
|
2092
|
+
const update = tools.find((tool) => tool.name === 'update_default_prompt');
|
|
2093
|
+
const unchanged = await update.execute('call_same', { prompt: 'Base persona' });
|
|
2094
|
+
expect(unchanged.details).toMatchObject({ updated: false, unchanged: true, defaultPrompt: 'Base persona' });
|
|
2095
|
+
const beforeRevision = service.snapshot().assistants.find((item) => item.id === assistant.id).revision;
|
|
2096
|
+
const saved = await update.execute('call_set', { prompt: 'Reply only in Chinese.' });
|
|
2097
|
+
expect(saved.details).toMatchObject({ updated: true, defaultPrompt: 'Reply only in Chinese.' });
|
|
2098
|
+
expect(saved.terminate).toBe(false);
|
|
2099
|
+
const after = service.snapshot().assistants.find((item) => item.id === assistant.id);
|
|
2100
|
+
expect(after.defaultPrompt).toBe('Reply only in Chinese.');
|
|
2101
|
+
expect(after.revision).toBe(beforeRevision + 1);
|
|
2102
|
+
phase = 'next';
|
|
2103
|
+
return { text: 'Default prompt saved.', bubbles: ['Default prompt saved.'] };
|
|
2104
|
+
}
|
|
2105
|
+
// Next turn: harness must see the persisted defaultPrompt from DB snapshot.
|
|
2106
|
+
expect(assistant.defaultPrompt).toBe('Reply only in Chinese.');
|
|
2107
|
+
const get = tools.find((tool) => tool.name === 'get_assistant_settings');
|
|
2108
|
+
const live = await get.execute('call_live', {});
|
|
2109
|
+
expect(live.details.settings.defaultPrompt).toBe('Reply only in Chinese.');
|
|
2110
|
+
return { text: 'Confirmed.', bubbles: ['Confirmed.'] };
|
|
2111
|
+
},
|
|
2112
|
+
});
|
|
2113
|
+
const host = service.createAssistant({ ...assistantInput, defaultPrompt: 'Base persona' });
|
|
2114
|
+
expect(host.defaultPrompt).toBe('Base persona');
|
|
2115
|
+
const tipBeforeWrite = tips.length;
|
|
2116
|
+
|
|
2117
|
+
await settleSend(service, host.id, {
|
|
2118
|
+
messageID: 'client_settings_read',
|
|
2119
|
+
parts: [{ type: 'text', text: '看看我的默认提示词' }],
|
|
2120
|
+
});
|
|
2121
|
+
await settleSend(service, host.id, {
|
|
2122
|
+
messageID: 'client_settings_write',
|
|
2123
|
+
parts: [{ type: 'text', text: '把默认提示词改成只用中文回复' }],
|
|
2124
|
+
});
|
|
2125
|
+
const tipAfterWrite = tips.length;
|
|
2126
|
+
expect(tipAfterWrite).toBeGreaterThan(tipBeforeWrite);
|
|
2127
|
+
expect(service.snapshot().assistants.find((item) => item.id === host.id).defaultPrompt).toBe('Reply only in Chinese.');
|
|
2128
|
+
|
|
2129
|
+
await settleSend(service, host.id, {
|
|
2130
|
+
messageID: 'client_settings_next',
|
|
2131
|
+
parts: [{ type: 'text', text: '确认一下默认提示词' }],
|
|
2132
|
+
});
|
|
2133
|
+
expect(harnessTurns.some((turn) => turn.phase === 'next' && turn.defaultPrompt === 'Reply only in Chinese.')).toBe(true);
|
|
2134
|
+
expect(harnessTurns[0].toolNames).toEqual(expect.arrayContaining([
|
|
2135
|
+
'get_assistant_settings',
|
|
2136
|
+
'update_default_prompt',
|
|
2137
|
+
]));
|
|
2138
|
+
service.close();
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
it('updates another assistant defaultPrompt by name without changing the host', async () => {
|
|
2142
|
+
const directory = root();
|
|
2143
|
+
const service = setup(directory, {}, {
|
|
2144
|
+
runContactTurn: async ({ tools }) => {
|
|
2145
|
+
const get = tools.find((tool) => tool.name === 'get_assistant_settings');
|
|
2146
|
+
const update = tools.find((tool) => tool.name === 'update_default_prompt');
|
|
2147
|
+
const seen = await get.execute('call_peer_get', { to: 'OpenCode 配置助手' });
|
|
2148
|
+
expect(seen.details.settings).toMatchObject({
|
|
2149
|
+
name: 'OpenCode 配置助手',
|
|
2150
|
+
defaultPrompt: 'peer persona',
|
|
2151
|
+
});
|
|
2152
|
+
const saved = await update.execute('call_peer_set', {
|
|
2153
|
+
to: 'OpenCode 配置助手',
|
|
2154
|
+
prompt: 'new peer persona',
|
|
2155
|
+
});
|
|
2156
|
+
expect(saved.details).toMatchObject({
|
|
2157
|
+
updated: true,
|
|
2158
|
+
defaultPrompt: 'new peer persona',
|
|
2159
|
+
name: 'OpenCode 配置助手',
|
|
2160
|
+
});
|
|
2161
|
+
return { text: 'Updated peer.', bubbles: ['Updated peer.'] };
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
const host = service.createAssistant({ ...assistantInput, name: 'Host', defaultPrompt: 'host persona' });
|
|
2165
|
+
const peer = service.createAssistant({
|
|
2166
|
+
...assistantInput,
|
|
2167
|
+
name: 'OpenCode 配置助手',
|
|
2168
|
+
defaultPrompt: 'peer persona',
|
|
2169
|
+
});
|
|
2170
|
+
await settleSend(service, host.id, {
|
|
2171
|
+
messageID: 'client_peer_prompt',
|
|
2172
|
+
parts: [{ type: 'text', text: '改 OpenCode 配置助手的默认提示词' }],
|
|
2173
|
+
});
|
|
2174
|
+
expect(service.snapshot().assistants.find((item) => item.id === host.id).defaultPrompt).toBe('host persona');
|
|
2175
|
+
expect(service.snapshot().assistants.find((item) => item.id === peer.id).defaultPrompt).toBe('new peer persona');
|
|
2176
|
+
service.close();
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
it('retries update_default_prompt once on revision_conflict and reports persistent conflict', async () => {
|
|
2180
|
+
const directory = root();
|
|
2181
|
+
let host;
|
|
2182
|
+
let mode = 'retry_success';
|
|
2183
|
+
const service = setup(directory, {}, {
|
|
2184
|
+
runContactTurn: async ({ tools }) => {
|
|
2185
|
+
const update = tools.find((tool) => tool.name === 'update_default_prompt');
|
|
2186
|
+
if (mode === 'retry_success') {
|
|
2187
|
+
// Concurrent name write lands during the pre-CAS yield → first CAS conflicts, retry succeeds.
|
|
2188
|
+
const pending = update.execute('call_retry', { prompt: 'After conflict retry' });
|
|
2189
|
+
await service.updateAssistant(host.id, {
|
|
2190
|
+
expectedRevision: service.snapshot().assistants.find((item) => item.id === host.id).revision,
|
|
2191
|
+
name: 'ConcurrentRename',
|
|
2192
|
+
});
|
|
2193
|
+
const result = await pending;
|
|
2194
|
+
expect(result.details).toMatchObject({ updated: true, defaultPrompt: 'After conflict retry' });
|
|
2195
|
+
expect(result.details.error).toBeUndefined();
|
|
2196
|
+
mode = 'retry_fail';
|
|
2197
|
+
return { text: 'Retried ok.', bubbles: ['Retried ok.'] };
|
|
2198
|
+
}
|
|
2199
|
+
// Two concurrent writes during attempt 0 and attempt 1 yields → both CAS fail.
|
|
2200
|
+
const pending = update.execute('call_fail', { prompt: 'Should fail' });
|
|
2201
|
+
await service.updateAssistant(host.id, {
|
|
2202
|
+
expectedRevision: service.snapshot().assistants.find((item) => item.id === host.id).revision,
|
|
2203
|
+
name: 'BumpOne',
|
|
2204
|
+
});
|
|
2205
|
+
await Promise.resolve();
|
|
2206
|
+
await service.updateAssistant(host.id, {
|
|
2207
|
+
expectedRevision: service.snapshot().assistants.find((item) => item.id === host.id).revision,
|
|
2208
|
+
name: 'BumpTwo',
|
|
2209
|
+
});
|
|
2210
|
+
const failed = await pending;
|
|
2211
|
+
expect(failed.details.error).toBe('revision_conflict');
|
|
2212
|
+
expect(failed.terminate).toBe(true);
|
|
2213
|
+
return { text: 'Conflict reported.', bubbles: ['Conflict reported.'] };
|
|
2214
|
+
},
|
|
2215
|
+
});
|
|
2216
|
+
host = service.createAssistant({ ...assistantInput, defaultPrompt: 'Original' });
|
|
2217
|
+
await settleSend(service, host.id, {
|
|
2218
|
+
messageID: 'client_prompt_retry',
|
|
2219
|
+
parts: [{ type: 'text', text: '改默认提示词' }],
|
|
2220
|
+
});
|
|
2221
|
+
expect(service.snapshot().assistants.find((item) => item.id === host.id)).toMatchObject({
|
|
2222
|
+
defaultPrompt: 'After conflict retry',
|
|
2223
|
+
name: 'ConcurrentRename',
|
|
2224
|
+
});
|
|
2225
|
+
await settleSend(service, host.id, {
|
|
2226
|
+
messageID: 'client_prompt_conflict',
|
|
2227
|
+
parts: [{ type: 'text', text: '再改默认提示词' }],
|
|
2228
|
+
});
|
|
2229
|
+
// Persistent conflict must not apply the failed write.
|
|
2230
|
+
expect(service.snapshot().assistants.find((item) => item.id === host.id).defaultPrompt).toBe('After conflict retry');
|
|
2231
|
+
service.close();
|
|
2232
|
+
});
|
|
2233
|
+
|
|
1851
2234
|
it('creates a scheduled task from schedule_task and persists the schedule card', async () => {
|
|
1852
2235
|
const directory = root();
|
|
1853
2236
|
const upserts = [];
|
|
@@ -2175,9 +2558,24 @@ describe('assistants service', () => {
|
|
|
2175
2558
|
service.close();
|
|
2176
2559
|
});
|
|
2177
2560
|
|
|
2178
|
-
it('subscribes assigned sessions so idle updates the card,
|
|
2561
|
+
it('subscribes assigned sessions so idle updates the card, resumes contact LLM, and skips canned settle bubbles', async () => {
|
|
2179
2562
|
const directory = root();
|
|
2180
|
-
|
|
2563
|
+
let time = 5_000;
|
|
2564
|
+
const resumes = [];
|
|
2565
|
+
const service = setup(directory, {
|
|
2566
|
+
messages: async () => ({
|
|
2567
|
+
data: [{
|
|
2568
|
+
info: { id: 'msg_worker', role: 'assistant' },
|
|
2569
|
+
parts: [{ type: 'text', text: 'Worker finished login fix.' }],
|
|
2570
|
+
}],
|
|
2571
|
+
}),
|
|
2572
|
+
}, {
|
|
2573
|
+
clock: () => time,
|
|
2574
|
+
runContactTurn: async ({ userText }) => {
|
|
2575
|
+
resumes.push(userText);
|
|
2576
|
+
return { text: '登录修复已完成。', bubbles: ['登录修复已完成。'] };
|
|
2577
|
+
},
|
|
2578
|
+
});
|
|
2181
2579
|
const assistant = service.createAssistant(assistantInput);
|
|
2182
2580
|
service.appendContactCard(assistant.id, {
|
|
2183
2581
|
cardType: 'session',
|
|
@@ -2193,6 +2591,8 @@ describe('assistants service', () => {
|
|
|
2193
2591
|
activeContactTurn: null,
|
|
2194
2592
|
});
|
|
2195
2593
|
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
|
|
2594
|
+
const resumeID = assignedSessionResumeMessageID(assistant.id, 'ses_work', 'complete', 5_000);
|
|
2595
|
+
await service.whenContactTurnSettled(resumeID);
|
|
2196
2596
|
const page = service.contactMessages(assistant.id);
|
|
2197
2597
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0]).toMatchObject({
|
|
2198
2598
|
type: 'card',
|
|
@@ -2200,17 +2600,27 @@ describe('assistants service', () => {
|
|
|
2200
2600
|
sessionID: 'ses_work',
|
|
2201
2601
|
status: 'complete',
|
|
2202
2602
|
});
|
|
2203
|
-
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(
|
|
2603
|
+
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(0);
|
|
2604
|
+
expect(page.messages.some((message) => message.text === '登录修复已完成。')).toBe(true);
|
|
2605
|
+
expect(page.messages.some((message) => message.role === 'user' && message.messageID === resumeID)).toBe(false);
|
|
2606
|
+
expect(resumes).toHaveLength(1);
|
|
2607
|
+
expect(resumes[0]).toContain('ses_work');
|
|
2608
|
+
expect(resumes[0]).toContain('complete');
|
|
2609
|
+
expect(resumes[0]).toContain('Login');
|
|
2610
|
+
expect(resumes[0]).toContain('Worker finished login fix.');
|
|
2204
2611
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2205
2612
|
assignedSessionIDs: [],
|
|
2206
2613
|
working: false,
|
|
2614
|
+
activeContactTurn: null,
|
|
2207
2615
|
});
|
|
2616
|
+
// Same complete watch → changed=false → no second resume.
|
|
2208
2617
|
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(false);
|
|
2209
|
-
expect(
|
|
2618
|
+
expect(resumes).toHaveLength(1);
|
|
2619
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === '登录修复已完成。')).toHaveLength(1);
|
|
2210
2620
|
service.close();
|
|
2211
2621
|
});
|
|
2212
2622
|
|
|
2213
|
-
it('reconciles a missed assigned-session idle on boot and the 60s timer', async () => {
|
|
2623
|
+
it('reconciles a missed assigned-session idle on boot and the 60s timer with resume and no canned settle', async () => {
|
|
2214
2624
|
const directory = root();
|
|
2215
2625
|
const first = setup(directory);
|
|
2216
2626
|
const assistant = first.createAssistant(assistantInput);
|
|
@@ -2229,6 +2639,8 @@ describe('assistants service', () => {
|
|
|
2229
2639
|
first.close();
|
|
2230
2640
|
|
|
2231
2641
|
let tick;
|
|
2642
|
+
let time = 7_000;
|
|
2643
|
+
const resumes = [];
|
|
2232
2644
|
const service = setup(directory, {
|
|
2233
2645
|
get: async ({ sessionID }) => (
|
|
2234
2646
|
sessionID === 'ses_missed' || sessionID === 'ses_timer'
|
|
@@ -2237,24 +2649,38 @@ describe('assistants service', () => {
|
|
|
2237
2649
|
),
|
|
2238
2650
|
messages: async ({ sessionID }) => (
|
|
2239
2651
|
sessionID === 'ses_missed' || sessionID === 'ses_timer'
|
|
2240
|
-
? {
|
|
2652
|
+
? {
|
|
2653
|
+
data: [{
|
|
2654
|
+
info: { id: `msg_${sessionID}`, role: 'assistant', time: { completed: 1 } },
|
|
2655
|
+
parts: [{ type: 'text', text: `done:${sessionID}` }],
|
|
2656
|
+
}],
|
|
2657
|
+
}
|
|
2241
2658
|
: { data: [] }
|
|
2242
2659
|
),
|
|
2243
2660
|
}, {
|
|
2661
|
+
clock: () => time,
|
|
2244
2662
|
setIntervalFn: (fn) => {
|
|
2245
2663
|
tick = fn;
|
|
2246
2664
|
return 1;
|
|
2247
2665
|
},
|
|
2666
|
+
runContactTurn: async ({ userText }) => {
|
|
2667
|
+
resumes.push(userText);
|
|
2668
|
+
const label = userText.includes('ses_timer') ? 'timer-done' : 'missed-done';
|
|
2669
|
+
return { text: label, bubbles: [label] };
|
|
2670
|
+
},
|
|
2248
2671
|
});
|
|
2249
2672
|
await service.reconcile();
|
|
2673
|
+
await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_missed', 'complete', 7_000));
|
|
2250
2674
|
let page = service.contactMessages(assistant.id);
|
|
2251
2675
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('complete');
|
|
2252
|
-
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(
|
|
2676
|
+
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(0);
|
|
2677
|
+
expect(page.messages.some((message) => message.text === 'missed-done')).toBe(true);
|
|
2253
2678
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2254
2679
|
assignedSessionIDs: [],
|
|
2255
2680
|
working: false,
|
|
2256
2681
|
});
|
|
2257
2682
|
|
|
2683
|
+
time = 8_000;
|
|
2258
2684
|
service.appendContactCard(assistant.id, {
|
|
2259
2685
|
cardType: 'session',
|
|
2260
2686
|
sessionID: 'ses_timer',
|
|
@@ -2265,15 +2691,20 @@ describe('assistants service', () => {
|
|
|
2265
2691
|
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
2266
2692
|
expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual(['ses_timer']);
|
|
2267
2693
|
await tick();
|
|
2694
|
+
await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_timer', 'complete', 8_000));
|
|
2268
2695
|
page = service.contactMessages(assistant.id);
|
|
2269
2696
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
|
|
2270
|
-
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(
|
|
2697
|
+
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(0);
|
|
2698
|
+
expect(page.messages.some((message) => message.text === 'timer-done')).toBe(true);
|
|
2699
|
+
expect(resumes).toHaveLength(2);
|
|
2271
2700
|
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
2272
2701
|
service.close();
|
|
2273
2702
|
});
|
|
2274
2703
|
|
|
2275
|
-
it('reconciles a missing assigned session as complete and an assistant error as failed', async () => {
|
|
2704
|
+
it('reconciles a missing assigned session as complete and an assistant error as failed without settle bubbles', async () => {
|
|
2276
2705
|
const directory = root();
|
|
2706
|
+
let time = 9_000;
|
|
2707
|
+
const resumes = [];
|
|
2277
2708
|
const service = setup(directory, {
|
|
2278
2709
|
get: async ({ sessionID }) => {
|
|
2279
2710
|
if (sessionID === 'ses_gone') return { error: { status: 404 } };
|
|
@@ -2281,9 +2712,25 @@ describe('assistants service', () => {
|
|
|
2281
2712
|
return { data: { id: sessionID } };
|
|
2282
2713
|
},
|
|
2283
2714
|
messages: async ({ sessionID }) => {
|
|
2284
|
-
if (sessionID === 'ses_fail')
|
|
2715
|
+
if (sessionID === 'ses_fail') {
|
|
2716
|
+
return {
|
|
2717
|
+
data: [{
|
|
2718
|
+
info: { id: 'msg_fail', role: 'assistant', error: { message: 'boom' } },
|
|
2719
|
+
parts: [{ type: 'text', text: 'worker boom' }],
|
|
2720
|
+
}],
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2285
2723
|
return { data: [] };
|
|
2286
2724
|
},
|
|
2725
|
+
}, {
|
|
2726
|
+
clock: () => time,
|
|
2727
|
+
runContactTurn: async ({ userText }) => {
|
|
2728
|
+
resumes.push(userText);
|
|
2729
|
+
return {
|
|
2730
|
+
text: userText.includes('error') ? '失败摘要' : '完成摘要',
|
|
2731
|
+
bubbles: [userText.includes('error') ? '失败摘要' : '完成摘要'],
|
|
2732
|
+
};
|
|
2733
|
+
},
|
|
2287
2734
|
});
|
|
2288
2735
|
const assistant = service.createAssistant(assistantInput);
|
|
2289
2736
|
service.appendContactCard(assistant.id, {
|
|
@@ -2301,26 +2748,37 @@ describe('assistants service', () => {
|
|
|
2301
2748
|
status: 'busy',
|
|
2302
2749
|
});
|
|
2303
2750
|
await service.reconcile();
|
|
2751
|
+
await Promise.all([
|
|
2752
|
+
service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_gone', 'complete', 9_000)),
|
|
2753
|
+
service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_fail', 'error', 9_000)),
|
|
2754
|
+
]);
|
|
2304
2755
|
const cards = service.contactMessages(assistant.id).messages
|
|
2305
2756
|
.filter((message) => message.parts.some((part) => part.type === 'card'))
|
|
2306
2757
|
.map((message) => message.parts[0]);
|
|
2307
2758
|
expect(cards.find((card) => card.sessionID === 'ses_gone')?.status).toBe('complete');
|
|
2308
2759
|
expect(cards.find((card) => card.sessionID === 'ses_fail')?.status).toBe('error');
|
|
2309
2760
|
const texts = service.contactMessages(assistant.id).messages.map((message) => message.text);
|
|
2310
|
-
expect(texts.filter((text) => text === 'oc.settle.complete')).toHaveLength(
|
|
2311
|
-
expect(texts.filter((text) => text === 'oc.settle.error')).toHaveLength(
|
|
2761
|
+
expect(texts.filter((text) => text === 'oc.settle.complete')).toHaveLength(0);
|
|
2762
|
+
expect(texts.filter((text) => text === 'oc.settle.error')).toHaveLength(0);
|
|
2763
|
+
expect(texts).toEqual(expect.arrayContaining(['完成摘要', '失败摘要']));
|
|
2764
|
+
expect(resumes.some((text) => text.includes('ses_gone') && text.includes('complete'))).toBe(true);
|
|
2765
|
+
expect(resumes.some((text) => text.includes('ses_fail') && text.includes('error'))).toBe(true);
|
|
2312
2766
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2313
2767
|
assignedSessionIDs: [],
|
|
2314
2768
|
working: false,
|
|
2315
2769
|
});
|
|
2770
|
+
const resumeCount = resumes.length;
|
|
2316
2771
|
await service.reconcile();
|
|
2317
|
-
expect(
|
|
2318
|
-
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.
|
|
2772
|
+
expect(resumes).toHaveLength(resumeCount);
|
|
2773
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(0);
|
|
2774
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.error')).toHaveLength(0);
|
|
2319
2775
|
service.close();
|
|
2320
2776
|
});
|
|
2321
2777
|
|
|
2322
2778
|
it('does not settle in-flight watches on fetch failure or while the session is still running', async () => {
|
|
2323
2779
|
const directory = root();
|
|
2780
|
+
let time = 10_000;
|
|
2781
|
+
const resumes = [];
|
|
2324
2782
|
const service = setup(directory, {
|
|
2325
2783
|
get: async ({ sessionID }) => {
|
|
2326
2784
|
if (sessionID === 'ses_down') throw new Error('network');
|
|
@@ -2329,10 +2787,23 @@ describe('assistants service', () => {
|
|
|
2329
2787
|
return { data: { id: sessionID } };
|
|
2330
2788
|
},
|
|
2331
2789
|
messages: async ({ sessionID }) => {
|
|
2332
|
-
if (sessionID === 'ses_idle')
|
|
2790
|
+
if (sessionID === 'ses_idle') {
|
|
2791
|
+
return {
|
|
2792
|
+
data: [{
|
|
2793
|
+
info: { id: 'msg_idle', role: 'assistant', time: { completed: 1 } },
|
|
2794
|
+
parts: [{ type: 'text', text: 'idle worker' }],
|
|
2795
|
+
}],
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2333
2798
|
if (sessionID === 'ses_busy') return { data: [{ info: { id: 'msg_busy', role: 'assistant' } }] };
|
|
2334
2799
|
throw new Error('should not settle from messages after get failure');
|
|
2335
2800
|
},
|
|
2801
|
+
}, {
|
|
2802
|
+
clock: () => time,
|
|
2803
|
+
runContactTurn: async ({ userText }) => {
|
|
2804
|
+
resumes.push(userText);
|
|
2805
|
+
return { text: 'idle-resume', bubbles: ['idle-resume'] };
|
|
2806
|
+
},
|
|
2336
2807
|
});
|
|
2337
2808
|
const assistant = service.createAssistant(assistantInput);
|
|
2338
2809
|
service.appendContactCard(assistant.id, {
|
|
@@ -2357,6 +2828,7 @@ describe('assistants service', () => {
|
|
|
2357
2828
|
status: 'busy',
|
|
2358
2829
|
});
|
|
2359
2830
|
await service.reconcile();
|
|
2831
|
+
await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_idle', 'complete', 10_000));
|
|
2360
2832
|
const cards = Object.fromEntries(service.contactMessages(assistant.id).messages
|
|
2361
2833
|
.filter((message) => message.parts.some((part) => part.type === 'card'))
|
|
2362
2834
|
.map((message) => [message.parts[0].sessionID, message.parts[0].status]));
|
|
@@ -2365,7 +2837,9 @@ describe('assistants service', () => {
|
|
|
2365
2837
|
ses_busy: 'busy',
|
|
2366
2838
|
ses_idle: 'complete',
|
|
2367
2839
|
});
|
|
2368
|
-
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(
|
|
2840
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(0);
|
|
2841
|
+
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'idle-resume')).toBe(true);
|
|
2842
|
+
expect(resumes).toHaveLength(1);
|
|
2369
2843
|
expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
|
|
2370
2844
|
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
2371
2845
|
service.close();
|
|
@@ -2374,13 +2848,26 @@ describe('assistants service', () => {
|
|
|
2374
2848
|
it('does not rewrite a reconciled session.error as complete on a later idle poll', async () => {
|
|
2375
2849
|
const directory = root();
|
|
2376
2850
|
let idle = false;
|
|
2851
|
+
let time = 11_000;
|
|
2852
|
+
const resumes = [];
|
|
2377
2853
|
const service = setup(directory, {
|
|
2378
2854
|
get: async ({ sessionID }) => (
|
|
2379
2855
|
sessionID === 'ses_err'
|
|
2380
2856
|
? { data: idle ? { id: 'ses_err', status: { type: 'idle' } } : { id: 'ses_err', error: { message: 'boom' } } }
|
|
2381
2857
|
: { data: { id: sessionID } }
|
|
2382
2858
|
),
|
|
2383
|
-
messages: async () => ({
|
|
2859
|
+
messages: async () => ({
|
|
2860
|
+
data: [{
|
|
2861
|
+
info: { id: 'msg_err', role: 'assistant', time: { completed: 1 } },
|
|
2862
|
+
parts: [{ type: 'text', text: 'err body' }],
|
|
2863
|
+
}],
|
|
2864
|
+
}),
|
|
2865
|
+
}, {
|
|
2866
|
+
clock: () => time,
|
|
2867
|
+
runContactTurn: async ({ userText }) => {
|
|
2868
|
+
resumes.push(userText);
|
|
2869
|
+
return { text: 'error-resume', bubbles: ['error-resume'] };
|
|
2870
|
+
},
|
|
2384
2871
|
});
|
|
2385
2872
|
const assistant = service.createAssistant(assistantInput);
|
|
2386
2873
|
service.appendContactCard(assistant.id, {
|
|
@@ -2391,12 +2878,19 @@ describe('assistants service', () => {
|
|
|
2391
2878
|
status: 'busy',
|
|
2392
2879
|
});
|
|
2393
2880
|
await service.reconcile();
|
|
2881
|
+
await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_err', 'error', 11_000));
|
|
2394
2882
|
expect(service.contactMessages(assistant.id).messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
2395
|
-
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.error')).toBe(
|
|
2883
|
+
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.error')).toBe(false);
|
|
2884
|
+
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'error-resume')).toBe(true);
|
|
2885
|
+
expect(resumes).toHaveLength(1);
|
|
2886
|
+
expect(resumes[0]).toContain('error');
|
|
2396
2887
|
idle = true;
|
|
2888
|
+
time = 12_000;
|
|
2397
2889
|
await service.reconcile();
|
|
2398
2890
|
expect(service.contactMessages(assistant.id).messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
2399
2891
|
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
|
|
2892
|
+
// Later idle must not resume complete after error.
|
|
2893
|
+
expect(resumes).toHaveLength(1);
|
|
2400
2894
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2401
2895
|
assignedSessionIDs: [],
|
|
2402
2896
|
working: false,
|
|
@@ -2404,9 +2898,17 @@ describe('assistants service', () => {
|
|
|
2404
2898
|
service.close();
|
|
2405
2899
|
});
|
|
2406
2900
|
|
|
2407
|
-
it('accepts a session-goal settle into the same card without mutating the worker', () => {
|
|
2901
|
+
it('accepts a session-goal settle into the same card without mutating the worker or writing settle text', async () => {
|
|
2408
2902
|
const directory = root();
|
|
2409
|
-
|
|
2903
|
+
let time = 13_000;
|
|
2904
|
+
const resumes = [];
|
|
2905
|
+
const service = setup(directory, {}, {
|
|
2906
|
+
clock: () => time,
|
|
2907
|
+
runContactTurn: async ({ userText }) => {
|
|
2908
|
+
resumes.push(userText);
|
|
2909
|
+
return { text: 'goal-resume', bubbles: ['goal-resume'] };
|
|
2910
|
+
},
|
|
2911
|
+
});
|
|
2410
2912
|
const assistant = service.createAssistant(assistantInput);
|
|
2411
2913
|
service.appendContactCard(assistant.id, {
|
|
2412
2914
|
cardType: 'session',
|
|
@@ -2416,16 +2918,27 @@ describe('assistants service', () => {
|
|
|
2416
2918
|
status: 'busy',
|
|
2417
2919
|
});
|
|
2418
2920
|
expect(service.reportAssignedSessionSettle('ses_goal', 'complete')).toBe(true);
|
|
2921
|
+
await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_goal', 'complete', 13_000));
|
|
2419
2922
|
const page = service.contactMessages(assistant.id);
|
|
2420
2923
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('complete');
|
|
2421
|
-
expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(
|
|
2924
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
|
|
2925
|
+
expect(page.messages.some((message) => message.text === 'goal-resume')).toBe(true);
|
|
2926
|
+
expect(resumes).toHaveLength(1);
|
|
2422
2927
|
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
2423
2928
|
service.close();
|
|
2424
2929
|
});
|
|
2425
2930
|
|
|
2426
|
-
it('maps question and error onto the same card and does not rewrite error as complete', () => {
|
|
2931
|
+
it('maps question and error onto the same card and does not rewrite error as complete', async () => {
|
|
2427
2932
|
const directory = root();
|
|
2428
|
-
|
|
2933
|
+
let time = 14_000;
|
|
2934
|
+
const resumes = [];
|
|
2935
|
+
const service = setup(directory, {}, {
|
|
2936
|
+
clock: () => time,
|
|
2937
|
+
runContactTurn: async ({ userText }) => {
|
|
2938
|
+
resumes.push(userText);
|
|
2939
|
+
return { text: 'ask-error-resume', bubbles: ['ask-error-resume'] };
|
|
2940
|
+
},
|
|
2941
|
+
});
|
|
2429
2942
|
const assistant = service.createAssistant(assistantInput);
|
|
2430
2943
|
service.appendContactCard(assistant.id, {
|
|
2431
2944
|
cardType: 'session',
|
|
@@ -2437,20 +2950,27 @@ describe('assistants service', () => {
|
|
|
2437
2950
|
expect(service.processEvent({ type: 'question.asked', properties: { sessionID: 'ses_ask' } })).toBe(true);
|
|
2438
2951
|
let page = service.contactMessages(assistant.id);
|
|
2439
2952
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('question');
|
|
2440
|
-
|
|
2953
|
+
// question: card only — no canned settle bubble and no contact resume.
|
|
2954
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.question')).toBe(false);
|
|
2955
|
+
expect(resumes).toHaveLength(0);
|
|
2441
2956
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2442
2957
|
assignedSessionIDs: ['ses_ask'],
|
|
2443
2958
|
working: false,
|
|
2444
2959
|
});
|
|
2445
2960
|
expect(service.processEvent({ type: 'session.error', properties: { sessionID: 'ses_ask' } })).toBe(true);
|
|
2961
|
+
await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_ask', 'error', 14_000));
|
|
2446
2962
|
page = service.contactMessages(assistant.id);
|
|
2447
2963
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
2448
|
-
expect(page.messages.some((message) => message.text === 'oc.settle.error')).toBe(
|
|
2964
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.error')).toBe(false);
|
|
2965
|
+
expect(page.messages.some((message) => message.text === 'ask-error-resume')).toBe(true);
|
|
2966
|
+
expect(resumes).toHaveLength(1);
|
|
2967
|
+
expect(resumes[0]).toContain('error');
|
|
2449
2968
|
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_ask' } })).toBe(false);
|
|
2450
2969
|
expect(service.processEvent({ type: 'session.status', properties: { sessionID: 'ses_ask', status: { type: 'idle' } } })).toBe(false);
|
|
2451
2970
|
page = service.contactMessages(assistant.id);
|
|
2452
2971
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
2453
2972
|
expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
|
|
2973
|
+
expect(resumes).toHaveLength(1);
|
|
2454
2974
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2455
2975
|
assignedSessionIDs: [],
|
|
2456
2976
|
working: false,
|