@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.
Files changed (50) hide show
  1. package/dist/assets/AssistantView-CKmIS1cS.js +2 -0
  2. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-BdI4PT2H.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-2ewbRZE2.js} +4 -1
  5. package/dist/assets/MarkstreamRendererImpl-DXewO7S_.js +3 -0
  6. package/dist/assets/{MobileDetailNavigation-Bfs4CTUh.js → MobileDetailNavigation-BGaAyuBH.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-DtwVLkBL.js → MobileShareBridge-BAZoyeIP.js} +1 -1
  8. package/dist/assets/MobileSurface-BVb3xNWt.js +2 -0
  9. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-C8ukIdLA.js} +1 -1
  10. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-CkQ61pbr.js} +1 -1
  11. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-CSePZ_V8.js} +1 -1
  12. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-BmSIrZ81.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-2BYaWLo1.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-Dk4gBaOu.js} +1 -1
  15. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-DtPf-h-O.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-BBn8YW82.js → insertionBoundaries-BMhzPFft.js} +1 -1
  17. package/dist/assets/{main-Cd7NwXx5.js → main-BjeOhdcG.js} +4 -4
  18. package/dist/assets/{main-phpzFgMN.js → main-Ci3JmtpW.js} +2 -2
  19. package/dist/assets/{miniChat-D6UYe50H.js → miniChat-BYrjYEXJ.js} +2 -2
  20. package/dist/assets/{mobile-w2Ed9I5O.js → mobile-BVMNnPK2.js} +2 -2
  21. package/dist/assets/{multirun-CYuxJPBX.js → multirun-CmaTeZON.js} +1 -1
  22. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-cw9nrwpr.js} +1 -1
  23. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-COwia8Cs.js} +2 -2
  24. package/dist/assets/{renderMobileApp-BvIE86_O.js → renderMobileApp-CXwwpY1t.js} +4 -4
  25. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-DI8l0VyZ.js} +1 -1
  26. package/dist/assets/{runtimeEndpointReset-lF5S7U4I.js → runtimeEndpointReset-BkAQXmXJ.js} +1 -1
  27. package/dist/assets/{sessionLookup-CNRDupYq.js → sessionLookup-Imc995rW.js} +1 -1
  28. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-BDFUND4v.js} +5 -5
  29. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-C5hZD1co.js} +1 -1
  30. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-e9HjIqGz.js} +1 -1
  31. package/dist/assets/{useMobileNavigationStore-DANrWekj.js → useMobileNavigationStore-CYbDxRTM.js} +1 -1
  32. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-BGy9_AhN.js} +1 -1
  33. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-DTGE0CDs.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/dist/mini-chat.html +2 -2
  36. package/dist/mobile.html +2 -2
  37. package/package.json +1 -1
  38. package/server/lib/assistants/DOCUMENTATION.md +20 -11
  39. package/server/lib/assistants/assign.js +56 -1
  40. package/server/lib/assistants/assign.test.js +60 -0
  41. package/server/lib/assistants/contact-tools.js +212 -1
  42. package/server/lib/assistants/contact-tools.test.js +151 -0
  43. package/server/lib/assistants/harness.js +56 -5
  44. package/server/lib/assistants/harness.test.js +17 -9
  45. package/server/lib/assistants/service.js +514 -16
  46. package/server/lib/assistants/service.test.js +546 -26
  47. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  48. package/dist/assets/MarkdownRenderer-v7n_D6nL.js +0 -2
  49. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  50. 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, appends a settle message, and clears working', () => {
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
- const service = setup(directory);
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(1);
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(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
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
- ? { data: [{ info: { id: `msg_${sessionID}`, role: 'assistant', time: { completed: 1 } } }] }
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(1);
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(2);
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') return { data: [{ info: { id: 'msg_fail', role: 'assistant', error: { message: 'boom' } } }] };
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(1);
2311
- expect(texts.filter((text) => text === 'oc.settle.error')).toHaveLength(1);
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(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
2318
- expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.error')).toHaveLength(1);
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') return { data: [{ info: { id: 'msg_idle', role: 'assistant', time: { completed: 1 } } }] };
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(1);
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 () => ({ data: [{ info: { id: 'msg_err', role: 'assistant', time: { completed: 1 } } }] }),
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(true);
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
- const service = setup(directory);
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(true);
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
- const service = setup(directory);
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
- expect(page.messages.some((message) => message.text === 'oc.settle.question')).toBe(true);
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(true);
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,