@openchambery/web 1.19.9 → 1.19.10-beta.2
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-DnN3m4Dp.js → AssistantView-BBCGdrHP.js} +1 -1
- package/dist/assets/{DiagramView-E3efnEX7.js → DiagramView-Bl4hBD9Q.js} +1 -1
- package/dist/assets/MarkdownRenderer-B3u13nMz.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-CdpiFWqs.js → MarkdownRendererImpl-D5gapBoS.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BPyoNg8p.js → MarkstreamRendererImpl-B0-BRwTb.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Cx7eDb4W.js → MobileDetailNavigation-K_D8tdQt.js} +1 -1
- package/dist/assets/{MobileShareBridge-B_go2MeO.js → MobileShareBridge-kMTuwMC5.js} +1 -1
- package/dist/assets/MobileSurface-iF2V5nla.js +2 -0
- package/dist/assets/{MultiRunWindow-DLCZOjCj.js → MultiRunWindow-DwexB9Qy.js} +1 -1
- package/dist/assets/{OnboardingScreen-C3-wzeSX.js → OnboardingScreen-DS2nugLX.js} +1 -1
- package/dist/assets/{SettingsView-C4mB0uFW.js → SettingsView-CQ1QLvr6.js} +2 -2
- package/dist/assets/{SettingsWindow-OGO76Rgi.js → SettingsWindow-FECd8wxM.js} +1 -1
- package/dist/assets/{TerminalView-Ck5P5GWU.js → TerminalView-DcMHfHtg.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BenN_1If.js → ToolOutputDialog-CSb529wq.js} +1 -1
- package/dist/assets/{appThemeRegistry-DAH35eaE.js → appThemeRegistry-BFv2g9rp.js} +1 -1
- package/dist/assets/desktopHosts-Dc2PS66M.js +1 -0
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +1 -0
- package/dist/assets/{gitApi-DdJQDVmv.js → gitApi-Dio9sS64.js} +1 -1
- package/dist/assets/{insertionBoundaries-DRvsnrxu.js → insertionBoundaries-nICJrO_N.js} +1 -1
- package/dist/assets/{main-CEdvbeao.js → main-CNBO8MzI.js} +2 -2
- package/dist/assets/{main-DPnPRJo9.js → main-CXS9iqzq.js} +22 -22
- package/dist/assets/{miniChat-9KRGMcES.js → miniChat-DtAiI_WP.js} +2 -2
- package/dist/assets/{mobile-CaTrLmyq.js → mobile-Z_dkmwkj.js} +2 -2
- package/dist/assets/{multirun-DZxLNwZx.js → multirun-BR5grr_M.js} +1 -1
- package/dist/assets/{projectMeta-B4mvLrV7.js → projectMeta-FU83d7PY.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-IARSnJ6n.js → renderElectronMiniChatApp-BUopzjBX.js} +2 -2
- package/dist/assets/{renderMobileApp-CvA1QBLw.js → renderMobileApp-kGq58GpN.js} +4 -4
- package/dist/assets/{runtimeConfig-CmL9jcr-.js → runtimeConfig-C8Ch7QlE.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-DUO6iUn_.js → runtimeEndpointReset-ByKPnj63.js} +1 -1
- package/dist/assets/{sessionLookup-CfllxmO1.js → sessionLookup-zfIdBJ9w.js} +1 -1
- package/dist/assets/{useAppFontEffects-COqtb3tR.js → useAppFontEffects-BH9JvmYW.js} +6 -6
- package/dist/assets/{useDesktopWindowControlsLayout-nDXj3p6q.js → useDesktopWindowControlsLayout-CLmxf8gm.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DOU5Xg5n.js → useEffectiveDirectory-fEbYkT-1.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-BawYHSru.js → useMobileNavigationStore-CeGGCHFp.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-JgEa86T5.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Dfd5Ra8Y.js → useWorkerHighlightedLines-CTP9abTJ.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +21 -10
- package/server/lib/assistants/contact-store.js +90 -5
- package/server/lib/assistants/contact-store.test.js +127 -2
- package/server/lib/assistants/contact-tools.js +107 -24
- package/server/lib/assistants/contact-tools.test.js +45 -3
- package/server/lib/assistants/harness.js +15 -5
- package/server/lib/assistants/harness.test.js +65 -3
- package/server/lib/assistants/service.js +76 -10
- package/server/lib/assistants/service.test.js +270 -11
- package/server/lib/llm/DOCUMENTATION.md +6 -0
- package/server/lib/llm/generate.js +4 -1
- package/server/lib/llm/generate.test.js +6 -1
- package/server/lib/opencode/DOCUMENTATION.md +1 -0
- package/server/lib/opencode/openchamber-routes.js +3 -0
- package/server/lib/opencode/openchamber-routes.test.js +43 -0
- package/server/lib/relay/DOCUMENTATION.md +1 -0
- package/dist/assets/MarkdownRenderer-BYryAKz2.js +0 -2
- package/dist/assets/MobileSurface-ByPOhQrz.js +0 -2
- package/dist/assets/desktopHosts-CWxex1c3.js +0 -1
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +0 -1
- package/dist/assets/useSessionAutoCleanup-R4Ke7did.js +0 -1
|
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { AssignError, ASSIGN_CODES, PROJECT_REQUIRED_MESSAGE } from './assign.js';
|
|
3
3
|
import {
|
|
4
4
|
ASSIGN_SESSION_TOOL_NAME,
|
|
5
|
+
CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
6
|
+
CLEAR_CHAT_HISTORY_TOOL_NAME,
|
|
5
7
|
CREATE_ASSISTANT_TOOL_NAME,
|
|
6
8
|
LIST_PROJECTS_TOOL_NAME,
|
|
7
9
|
LIST_SESSIONS_TOOL_NAME,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
resolveContactProviderModel,
|
|
21
23
|
resolvePeerAssistant,
|
|
22
24
|
stripContactToolFences,
|
|
25
|
+
userTextAuthorizesClearChatHistory,
|
|
23
26
|
} from './contact-tools.js';
|
|
24
27
|
|
|
25
28
|
describe('contact tool protocol', () => {
|
|
@@ -78,6 +81,7 @@ describe('contact tool protocol', () => {
|
|
|
78
81
|
it('detects 建助理 without treating 不要开编码 session as assign_session', () => {
|
|
79
82
|
const tools = [
|
|
80
83
|
NEW_CONVERSATION_TOOL_NAME,
|
|
84
|
+
CLEAR_CHAT_HISTORY_TOOL_NAME,
|
|
81
85
|
LIST_PROJECTS_TOOL_NAME,
|
|
82
86
|
LIST_SESSIONS_TOOL_NAME,
|
|
83
87
|
CREATE_ASSISTANT_TOOL_NAME,
|
|
@@ -90,6 +94,7 @@ describe('contact tool protocol', () => {
|
|
|
90
94
|
]);
|
|
91
95
|
expect(detectRequestedContactTools('每天 18:00 排一个 ping 定时任务', tools)).toEqual([SCHEDULE_TASK_TOOL_NAME]);
|
|
92
96
|
expect(detectRequestedContactTools('建会话写一个文件', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
|
|
97
|
+
expect(detectRequestedContactTools('对,你直接派给那个项目组,再建个会话去修这个问题', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
|
|
93
98
|
expect(detectRequestedContactTools('写一个文件', tools)).toEqual([]);
|
|
94
99
|
expect(detectRequestedContactTools('pwd', tools)).toEqual([]);
|
|
95
100
|
expect(detectRequestedContactTools('给 PeerQA 说一声 hello-from-assistant 写好了', tools)).toEqual([
|
|
@@ -98,11 +103,24 @@ describe('contact tool protocol', () => {
|
|
|
98
103
|
expect(detectRequestedContactTools('开新对话', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
|
|
99
104
|
expect(detectRequestedContactTools('new conversation please', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
|
|
100
105
|
expect(detectRequestedContactTools('clear chat', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
|
|
106
|
+
expect(detectRequestedContactTools('清除记忆', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
|
|
107
|
+
expect(detectRequestedContactTools('清空聊天记录', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
|
|
108
|
+
expect(detectRequestedContactTools('清除聊天记录', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
|
|
109
|
+
expect(detectRequestedContactTools('clear chat history', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
|
|
110
|
+
expect(detectRequestedContactTools('delete chat history', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
|
|
111
|
+
expect(detectRequestedContactTools('不要清除聊天记录', tools)).toEqual([]);
|
|
112
|
+
expect(detectRequestedContactTools('do not clear chat history', tools)).toEqual([]);
|
|
101
113
|
expect(detectRequestedContactTools('找项目 openchamber yee', tools)).toEqual([LIST_PROJECTS_TOOL_NAME]);
|
|
102
114
|
expect(detectRequestedContactTools('看看现有对话', tools)).toEqual([LIST_SESSIONS_TOOL_NAME]);
|
|
103
115
|
expect(detectRequestedContactTools('list sessions in that project', tools)).toEqual([LIST_SESSIONS_TOOL_NAME]);
|
|
104
116
|
expect(detectRequestedContactTools('开新对话', tools)).not.toContain(ASSIGN_SESSION_TOOL_NAME);
|
|
117
|
+
expect(detectRequestedContactTools('开新对话', tools)).not.toContain(CLEAR_CHAT_HISTORY_TOOL_NAME);
|
|
105
118
|
expect(detectRequestedContactTools('不要开编码 session', tools)).toEqual([]);
|
|
119
|
+
expect(userTextAuthorizesClearChatHistory('清除聊天记录')).toBe(true);
|
|
120
|
+
expect(userTextAuthorizesClearChatHistory('清空聊天记录')).toBe(true);
|
|
121
|
+
expect(userTextAuthorizesClearChatHistory('清除记忆')).toBe(false);
|
|
122
|
+
expect(userTextAuthorizesClearChatHistory('不要清除聊天记录')).toBe(false);
|
|
123
|
+
expect(userTextAuthorizesClearChatHistory('开新对话')).toBe(false);
|
|
106
124
|
});
|
|
107
125
|
|
|
108
126
|
it('parses bash fences when the pi coding tools are allowed', () => {
|
|
@@ -120,6 +138,7 @@ describe('contact tool protocol', () => {
|
|
|
120
138
|
it('tells DeepSeek to call tools from natural language, not slash commands', () => {
|
|
121
139
|
const prompt = formatContactToolsPrompt(createContactTools());
|
|
122
140
|
expect(prompt).toContain('new_conversation');
|
|
141
|
+
expect(prompt).toContain('clear_chat_history');
|
|
123
142
|
expect(prompt).toContain('list_projects');
|
|
124
143
|
expect(prompt).toContain('list_sessions');
|
|
125
144
|
expect(prompt).toContain('create_assistant');
|
|
@@ -127,12 +146,15 @@ describe('contact tool protocol', () => {
|
|
|
127
146
|
expect(prompt).toContain('message_assistant');
|
|
128
147
|
expect(prompt).toContain('assign_session');
|
|
129
148
|
expect(prompt).toContain('开新对话');
|
|
149
|
+
expect(prompt).toContain('清除记忆');
|
|
150
|
+
expect(prompt).toContain('清空聊天记录');
|
|
130
151
|
expect(prompt).toContain('找项目');
|
|
131
152
|
expect(prompt).toContain('现有对话');
|
|
132
153
|
expect(prompt).toContain('建助理');
|
|
133
154
|
expect(prompt).toContain('排定时任务');
|
|
134
155
|
expect(prompt).toContain('说一声');
|
|
135
156
|
expect(prompt).toContain('session/new');
|
|
157
|
+
expect(prompt).toContain('LLM memory only');
|
|
136
158
|
expect(prompt).not.toContain('/card');
|
|
137
159
|
expect(prompt).not.toContain('/dm');
|
|
138
160
|
expect(prompt).toContain('A reply without the tool call does nothing');
|
|
@@ -168,8 +190,10 @@ describe('contact tool protocol', () => {
|
|
|
168
190
|
describe('createContactTools', () => {
|
|
169
191
|
it('exposes create_assistant, schedule_task, and assign_session and returns success cards', async () => {
|
|
170
192
|
const onCard = vi.fn();
|
|
171
|
-
const
|
|
193
|
+
const clearContactMemory = vi.fn(async () => ({ reset: true, memoryCleared: true }));
|
|
194
|
+
const resetContact = vi.fn(async () => ({ reset: true, historyCleared: true }));
|
|
172
195
|
const tools = createContactTools({
|
|
196
|
+
clearContactMemory,
|
|
173
197
|
resetContact,
|
|
174
198
|
createAssistant: async (input) => ({
|
|
175
199
|
id: 'asst_flow',
|
|
@@ -204,6 +228,7 @@ describe('createContactTools', () => {
|
|
|
204
228
|
});
|
|
205
229
|
expect(tools.map((tool) => tool.name)).toEqual([
|
|
206
230
|
NEW_CONVERSATION_TOOL_NAME,
|
|
231
|
+
CLEAR_CHAT_HISTORY_TOOL_NAME,
|
|
207
232
|
LIST_PROJECTS_TOOL_NAME,
|
|
208
233
|
LIST_SESSIONS_TOOL_NAME,
|
|
209
234
|
CREATE_ASSISTANT_TOOL_NAME,
|
|
@@ -214,12 +239,19 @@ describe('createContactTools', () => {
|
|
|
214
239
|
expect(tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false);
|
|
215
240
|
|
|
216
241
|
const reset = await tools.find((tool) => tool.name === NEW_CONVERSATION_TOOL_NAME).execute('call_0', {});
|
|
217
|
-
expect(
|
|
242
|
+
expect(clearContactMemory).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(resetContact).not.toHaveBeenCalled();
|
|
218
244
|
expect(reset.details.card).toBeUndefined();
|
|
219
|
-
expect(reset.details
|
|
245
|
+
expect(reset.details).toMatchObject({ reset: true, memoryCleared: true });
|
|
220
246
|
expect(reset.terminate).toBe(true);
|
|
221
247
|
expect(reset.content[0].text).toBe(NEW_CONVERSATION_CONFIRM_BUBBLE);
|
|
222
248
|
|
|
249
|
+
const wiped = await tools.find((tool) => tool.name === CLEAR_CHAT_HISTORY_TOOL_NAME).execute('call_wipe', {});
|
|
250
|
+
expect(resetContact).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(wiped.details).toMatchObject({ reset: true, historyCleared: true });
|
|
252
|
+
expect(wiped.terminate).toBe(true);
|
|
253
|
+
expect(wiped.content[0].text).toBe(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
|
|
254
|
+
|
|
223
255
|
const created = await tools.find((tool) => tool.name === CREATE_ASSISTANT_TOOL_NAME).execute('call_1', { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' });
|
|
224
256
|
expect(created.details.card).toMatchObject({
|
|
225
257
|
type: 'card',
|
|
@@ -334,4 +366,14 @@ describe('confirmBubbleAfterContactReset', () => {
|
|
|
334
366
|
'I still see your dot.png and note.txt from earlier.',
|
|
335
367
|
])).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE]);
|
|
336
368
|
});
|
|
369
|
+
|
|
370
|
+
it('prefers the clear-history confirm when that tool ran', () => {
|
|
371
|
+
expect(confirmBubbleAfterContactReset([
|
|
372
|
+
CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
373
|
+
'I still see older cards.',
|
|
374
|
+
], CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE)).toEqual([CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE]);
|
|
375
|
+
expect(confirmBubbleAfterContactReset([
|
|
376
|
+
'leftover only',
|
|
377
|
+
], CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE)).toEqual([CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE]);
|
|
378
|
+
});
|
|
337
379
|
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Agent } from '@earendil-works/pi-agent-core';
|
|
2
2
|
import { isContactSpokenPreamble, splitContactBubbles } from './bubbles.js';
|
|
3
3
|
import {
|
|
4
|
+
CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
4
5
|
confirmBubbleAfterContactReset,
|
|
6
|
+
contactTurnClearedChatHistory,
|
|
5
7
|
contactTurnHasSuccessfulReset,
|
|
6
8
|
contactTurnHasToolResult,
|
|
7
9
|
detectRequestedContactTools,
|
|
@@ -75,7 +77,7 @@ export const CONTACT_SYSTEM_PROMPT = [
|
|
|
75
77
|
'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
|
|
76
78
|
'Do not expose tool traces, Activity, or editor actions.',
|
|
77
79
|
'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
|
|
78
|
-
'Understand natural language in any language, including Chinese: 开新对话 means new_conversation, 找项目 means list_projects, 现有对话 means list_sessions, 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
|
|
80
|
+
'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
|
|
79
81
|
'You receive the registered project catalog every turn. You CAN see those projects. Look them up yourself (fuzzy match label/name/path). Never say you cannot see the registered project list. Never ask for a raw filesystem path when a name matches. If the catalog is empty, tell the user to add a project in Settings.',
|
|
80
82
|
'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID.',
|
|
81
83
|
'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
|
|
@@ -521,8 +523,11 @@ export async function runContactTurn({
|
|
|
521
523
|
throw error;
|
|
522
524
|
}
|
|
523
525
|
const requested = detectRequestedContactTools(userText, contactTools.map((tool) => tool.name));
|
|
526
|
+
const hasRequestedResult = () => agent.state.messages.some((message) => (
|
|
527
|
+
message?.role === 'toolResult' && requested.includes(message.toolName)
|
|
528
|
+
));
|
|
524
529
|
let retried = false;
|
|
525
|
-
if (requested.length > 0 && !
|
|
530
|
+
if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
526
531
|
retried = true;
|
|
527
532
|
await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
|
|
528
533
|
if (agent.state.errorMessage) {
|
|
@@ -531,7 +536,7 @@ export async function runContactTurn({
|
|
|
531
536
|
throw error;
|
|
532
537
|
}
|
|
533
538
|
}
|
|
534
|
-
if (requested.length > 0 && !
|
|
539
|
+
if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
535
540
|
return {
|
|
536
541
|
text: MISSED_TOOL_FAILURE_BUBBLE,
|
|
537
542
|
bubbles: [MISSED_TOOL_FAILURE_BUBBLE],
|
|
@@ -543,12 +548,17 @@ export async function runContactTurn({
|
|
|
543
548
|
const outcome = extractContactTurnOutcome(agent.state.messages, retried);
|
|
544
549
|
const text = stripContactToolFences(outcome.text);
|
|
545
550
|
if (contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
546
|
-
const
|
|
551
|
+
const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
|
|
552
|
+
const preferredConfirm = historyCleared
|
|
553
|
+
? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
|
|
554
|
+
: NEW_CONVERSATION_CONFIRM_BUBBLE;
|
|
555
|
+
const bubbles = confirmBubbleAfterContactReset(splitContactBubbles(text), preferredConfirm);
|
|
547
556
|
return {
|
|
548
|
-
text: bubbles[0] ||
|
|
557
|
+
text: bubbles[0] || preferredConfirm,
|
|
549
558
|
bubbles,
|
|
550
559
|
cards: [],
|
|
551
560
|
reset: true,
|
|
561
|
+
historyCleared,
|
|
552
562
|
thinkingLevel: agent.state.thinkingLevel,
|
|
553
563
|
tools: [...agent.state.tools],
|
|
554
564
|
};
|
|
@@ -211,6 +211,65 @@ describe('createContactStreamFn', () => {
|
|
|
211
211
|
})
|
|
212
212
|
|
|
213
213
|
describe('runContactTurn', () => {
|
|
214
|
+
it('keeps all eleven tools and assigns a session after reading a skill', async () => {
|
|
215
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-mixed-'))
|
|
216
|
+
const home = path.join(workspace, 'home')
|
|
217
|
+
const skillDir = path.join(workspace, '.agents', 'skills', 'docs')
|
|
218
|
+
fs.mkdirSync(skillDir, { recursive: true })
|
|
219
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: docs\ndescription: Read documentation\n---\nRead project docs.\n')
|
|
220
|
+
const assignWork = vi.fn(async () => ({ sessionID: 'ses_mixed', directory: workspace, title: 'Fix', status: 'busy' }))
|
|
221
|
+
const tools = createContactTools({ assignWork })
|
|
222
|
+
const replies = [
|
|
223
|
+
'```openchamber-tool\n{"name":"read","arguments":{"path":".agents/skills/docs/SKILL.md"}}\n```',
|
|
224
|
+
'Only documentation search is available.',
|
|
225
|
+
`\`\`\`openchamber-tool\n${JSON.stringify({ name: 'assign_session', arguments: { prompt: 'Fix the issue', projectPath: workspace } })}\n\`\`\``,
|
|
226
|
+
'Session opened.',
|
|
227
|
+
]
|
|
228
|
+
let call = 0
|
|
229
|
+
const createChatCompletion = vi.fn(async ({ body }) => {
|
|
230
|
+
expect(body.messages[0].content).toContain('assign_session')
|
|
231
|
+
expect(body.messages[0].content).toContain('docs')
|
|
232
|
+
const content = replies[call++]
|
|
233
|
+
if (!content) throw new Error('Unexpected completion')
|
|
234
|
+
return { completion: { choices: [{ message: { content } }] } }
|
|
235
|
+
})
|
|
236
|
+
try {
|
|
237
|
+
const result = await runContactTurn({
|
|
238
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace, defaultPrompt: 'Keep replies short.' },
|
|
239
|
+
history: [], userText: '再建个会话去修这个问题', tools,
|
|
240
|
+
projects: [{ id: 'project', path: workspace }], skillHomeDir: home, createChatCompletion,
|
|
241
|
+
})
|
|
242
|
+
expect(result.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES, ...tools.map((tool) => tool.name)])
|
|
243
|
+
expect(assignWork).toHaveBeenCalledTimes(1)
|
|
244
|
+
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_mixed' })])
|
|
245
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(4)
|
|
246
|
+
} finally {
|
|
247
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
it('retries assignment after an unrelated read result', async () => {
|
|
251
|
+
const prompts = []
|
|
252
|
+
function AgentImpl(options) {
|
|
253
|
+
this.state = { ...options.initialState, messages: [] }
|
|
254
|
+
this.prompt = async (text) => {
|
|
255
|
+
prompts.push(text)
|
|
256
|
+
if (prompts.length === 1) {
|
|
257
|
+
this.state.messages.push({ role: 'toolResult', toolName: 'read', content: [{ type: 'text', text: 'Skill instructions' }] })
|
|
258
|
+
this.state.messages.push({ role: 'assistant', content: [{ type: 'text', text: 'Only document search is available.' }] })
|
|
259
|
+
} else {
|
|
260
|
+
this.state.messages.push({ role: 'user', content: text })
|
|
261
|
+
this.state.messages.push({ role: 'toolResult', toolName: 'assign_session', content: [{ type: 'text', text: 'Opened session.' }] })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const result = await runContactTurn({
|
|
266
|
+
assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
|
|
267
|
+
history: [], userText: '建会话', tools: createContactTools(),
|
|
268
|
+
createChatCompletion: vi.fn(), AgentImpl,
|
|
269
|
+
})
|
|
270
|
+
expect(prompts).toHaveLength(2)
|
|
271
|
+
expect(result.bubbles).toEqual(['Opened session.'])
|
|
272
|
+
})
|
|
214
273
|
it('runs pi-agent-core with thinking off and no tools by default', async () => {
|
|
215
274
|
const prompt = vi.fn(async function prompt() {
|
|
216
275
|
this.state.messages = [{
|
|
@@ -653,8 +712,9 @@ describe('runContactTurn', () => {
|
|
|
653
712
|
})
|
|
654
713
|
|
|
655
714
|
it('keeps only the new_conversation confirm and drops leftover attachment text', async () => {
|
|
656
|
-
const
|
|
657
|
-
const
|
|
715
|
+
const clearContactMemory = vi.fn(async () => ({ reset: true, memoryCleared: true }))
|
|
716
|
+
const resetContact = vi.fn(async () => ({ reset: true, historyCleared: true }))
|
|
717
|
+
const tools = createContactTools({ clearContactMemory, resetContact })
|
|
658
718
|
function AgentImpl(options) {
|
|
659
719
|
this.state = { ...options.initialState, messages: options.initialState.messages }
|
|
660
720
|
this.prompt = async () => {
|
|
@@ -687,8 +747,10 @@ describe('runContactTurn', () => {
|
|
|
687
747
|
tools,
|
|
688
748
|
AgentImpl,
|
|
689
749
|
})
|
|
690
|
-
expect(
|
|
750
|
+
expect(clearContactMemory).toHaveBeenCalledTimes(1)
|
|
751
|
+
expect(resetContact).not.toHaveBeenCalled()
|
|
691
752
|
expect(result.reset).toBe(true)
|
|
753
|
+
expect(result.historyCleared).toBe(false)
|
|
692
754
|
expect(result.cards).toEqual([])
|
|
693
755
|
expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
|
|
694
756
|
expect(result.bubbles.join('')).not.toContain('dot.png')
|
|
@@ -9,6 +9,7 @@ import { getWorktrees as defaultListWorktrees } from '../git/service.js';
|
|
|
9
9
|
import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
|
|
10
10
|
import {
|
|
11
11
|
CONTACT_SETTLE_TEXT,
|
|
12
|
+
clearContactMemory as clearContactMemoryStore,
|
|
12
13
|
contactHistoryForLlm,
|
|
13
14
|
deleteContactMessages,
|
|
14
15
|
ensureContactSchema,
|
|
@@ -23,12 +24,16 @@ import {
|
|
|
23
24
|
import {
|
|
24
25
|
ASSIGNED_SESSION_FALLBACK_BUBBLE,
|
|
25
26
|
boundSessionListLimit,
|
|
27
|
+
CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
26
28
|
confirmBubbleAfterContactReset,
|
|
29
|
+
contactTurnClearedChatHistory,
|
|
27
30
|
createContactTools,
|
|
28
31
|
filterRegisteredProjects,
|
|
29
32
|
matchesProjectQuery,
|
|
33
|
+
NEW_CONVERSATION_CONFIRM_BUBBLE,
|
|
30
34
|
normalizeRegisteredProjects,
|
|
31
35
|
sanitizeRegisteredProject,
|
|
36
|
+
userTextAuthorizesClearChatHistory,
|
|
32
37
|
} from './contact-tools.js';
|
|
33
38
|
import { AssignError, ASSIGN_CODES, assignSession, resolveAssignDirectory } from './assign.js';
|
|
34
39
|
import { runContactTurn as defaultRunContactTurn } from './harness.js';
|
|
@@ -1026,9 +1031,6 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1026
1031
|
if (!userText) fail('validation_error');
|
|
1027
1032
|
const userParts = userContactParts(input, userText);
|
|
1028
1033
|
const row = active(assistantID);
|
|
1029
|
-
// Snapshot history before admitting the user so the harness does not see a
|
|
1030
|
-
// duplicate of the current userText (prompt still receives userText).
|
|
1031
|
-
const history = contactHistoryForLlm(db, assistantID);
|
|
1032
1034
|
if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
|
|
1033
1035
|
let registeredProjects = [];
|
|
1034
1036
|
try {
|
|
@@ -1056,18 +1058,51 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1056
1058
|
messageID,
|
|
1057
1059
|
});
|
|
1058
1060
|
contactTurnSettlement(messageID);
|
|
1061
|
+
// Authorize wipe from this turn's real userText only — not tool args.
|
|
1062
|
+
const clearChatHistoryAuthorized = userTextAuthorizesClearChatHistory(userText);
|
|
1059
1063
|
const kickTurn = () => {
|
|
1060
1064
|
void inContactTurnLane(row.assistant_id, async () => {
|
|
1065
|
+
// Load the LLM window when the lane runs — not at admit time — so a
|
|
1066
|
+
// prior turn's clear-memory cannot leave queued work with stale history.
|
|
1067
|
+
// Exclude the admitted user row; prompt still receives userText/parts.
|
|
1068
|
+
// beforeOrdinal caps later-admitted queued user rows out of this turn.
|
|
1069
|
+
const admitted = db.prepare(
|
|
1070
|
+
'SELECT ordinal FROM assistant_contact_message WHERE message_id=?',
|
|
1071
|
+
).get(messageID);
|
|
1072
|
+
const parsedOrdinal = admitted?.ordinal == null ? NaN : Number(admitted.ordinal);
|
|
1073
|
+
const beforeOrdinal = Number.isFinite(parsedOrdinal) ? parsedOrdinal : null;
|
|
1074
|
+
const history = contactHistoryForLlm(db, row.assistant_id, {
|
|
1075
|
+
excludeMessageIDs: [messageID],
|
|
1076
|
+
beforeOrdinal,
|
|
1077
|
+
});
|
|
1061
1078
|
const assignedCards = [];
|
|
1062
1079
|
let contactResetThisTurn = false;
|
|
1080
|
+
let contactHistoryClearedThisTurn = false;
|
|
1063
1081
|
const tools = createContactTools({
|
|
1064
1082
|
assignWork: (params) => assignWork(row, params),
|
|
1065
1083
|
createAssistant: (toolInput) => createAssistant(toolInput),
|
|
1066
1084
|
scheduleTask: (params) => scheduleWork(row, params),
|
|
1067
1085
|
deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
|
|
1086
|
+
clearContactMemory: () => {
|
|
1087
|
+
// Cap the watermark at this turn's user ordinal — never global MAX,
|
|
1088
|
+
// or later-admitted queued users are permanently excluded from LLM history.
|
|
1089
|
+
const result = clearContactMemory(row.assistant_id, {
|
|
1090
|
+
upToOrdinal: beforeOrdinal,
|
|
1091
|
+
});
|
|
1092
|
+
contactResetThisTurn = true;
|
|
1093
|
+
return result;
|
|
1094
|
+
},
|
|
1068
1095
|
resetContact: () => {
|
|
1096
|
+
// Server core gate: model mis-select cannot self-authorize delete.
|
|
1097
|
+
if (!clearChatHistoryAuthorized) {
|
|
1098
|
+
throw new AssignError(
|
|
1099
|
+
ASSIGN_CODES.VALIDATION,
|
|
1100
|
+
'clear_chat_history requires explicit user wipe intent in this message.',
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1069
1103
|
const result = resetContact(row.assistant_id);
|
|
1070
1104
|
contactResetThisTurn = true;
|
|
1105
|
+
contactHistoryClearedThisTurn = true;
|
|
1071
1106
|
return result;
|
|
1072
1107
|
},
|
|
1073
1108
|
listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
|
|
@@ -1107,8 +1142,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1107
1142
|
return;
|
|
1108
1143
|
}
|
|
1109
1144
|
const resetThisTurn = contactResetThisTurn || generated?.reset === true;
|
|
1145
|
+
const historyClearedThisTurn = contactHistoryClearedThisTurn
|
|
1146
|
+
|| contactTurnClearedChatHistory(generated?.messages)
|
|
1147
|
+
|| generated?.historyCleared === true;
|
|
1148
|
+
const preferredConfirm = historyClearedThisTurn
|
|
1149
|
+
? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
|
|
1150
|
+
: NEW_CONVERSATION_CONFIRM_BUBBLE;
|
|
1110
1151
|
const bubbles = resetThisTurn
|
|
1111
|
-
? confirmBubbleAfterContactReset(generated?.bubbles)
|
|
1152
|
+
? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
|
|
1112
1153
|
: (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
|
|
1113
1154
|
const cards = resetThisTurn
|
|
1114
1155
|
? []
|
|
@@ -1134,8 +1175,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1134
1175
|
resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
|
|
1135
1176
|
return;
|
|
1136
1177
|
}
|
|
1137
|
-
//
|
|
1138
|
-
// Re-admit
|
|
1178
|
+
// clear_chat_history / resetContact wipe the transcript (including the
|
|
1179
|
+
// admitted user row). Re-admit that user message, then persist only the
|
|
1180
|
+
// confirm bubble. new_conversation keeps rows and only moves the watermark.
|
|
1139
1181
|
if (resetThisTurn) {
|
|
1140
1182
|
const existingUser = db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(messageID);
|
|
1141
1183
|
if (!existingUser) {
|
|
@@ -1201,8 +1243,32 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1201
1243
|
return listContactMessages(db, assistantID, { before: query.before, limit });
|
|
1202
1244
|
};
|
|
1203
1245
|
/**
|
|
1204
|
-
* Clear
|
|
1205
|
-
*
|
|
1246
|
+
* Clear LLM memory only: advance the durable context boundary so later
|
|
1247
|
+
* contactHistoryForLlm drops prior turns. Transcript GET keeps every row.
|
|
1248
|
+
* Failed writes roll back and keep the previous boundary. Does not call
|
|
1249
|
+
* OpenCode session/new.
|
|
1250
|
+
*/
|
|
1251
|
+
const clearContactMemory = (assistantID, options = {}) => {
|
|
1252
|
+
const row = editable(assistantID);
|
|
1253
|
+
const upToOrdinal = options && typeof options === 'object' ? options.upToOrdinal : undefined;
|
|
1254
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1255
|
+
try {
|
|
1256
|
+
const cleared = clearContactMemoryStore(db, row.assistant_id, {
|
|
1257
|
+
updatedAt: now(),
|
|
1258
|
+
...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
|
|
1259
|
+
});
|
|
1260
|
+
bump();
|
|
1261
|
+
db.exec('COMMIT');
|
|
1262
|
+
return { assistantID: row.assistant_id, reset: true, memoryCleared: true, afterOrdinal: cleared.afterOrdinal };
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
db.exec('ROLLBACK');
|
|
1265
|
+
throw error;
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
/**
|
|
1269
|
+
* Delete this assistant's OpenChamber contact transcript (messages, parts,
|
|
1270
|
+
* watches) and clear the context boundary. Intentional wipe only — also the
|
|
1271
|
+
* POST /contact/reset contract. Does not call OpenCode session/new.
|
|
1206
1272
|
*/
|
|
1207
1273
|
const resetContact = (assistantID) => {
|
|
1208
1274
|
const row = editable(assistantID);
|
|
@@ -1215,7 +1281,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1215
1281
|
db.exec('ROLLBACK');
|
|
1216
1282
|
throw error;
|
|
1217
1283
|
}
|
|
1218
|
-
return { assistantID: row.assistant_id, reset: true };
|
|
1284
|
+
return { assistantID: row.assistant_id, reset: true, historyCleared: true };
|
|
1219
1285
|
};
|
|
1220
1286
|
const appendContactCard = (assistantID, input) => {
|
|
1221
1287
|
const row = active(assistantID);
|
|
@@ -1371,5 +1437,5 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1371
1437
|
db.exec('ROLLBACK');
|
|
1372
1438
|
throw error;
|
|
1373
1439
|
}
|
|
1374
|
-
}, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
|
|
1440
|
+
}, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
|
|
1375
1441
|
};
|