@openchambery/web 1.19.7 → 1.19.8-beta.10
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-B3zIR_MP.js +2 -0
- package/dist/assets/{DiagramView-B2UtQ8mw.js → DiagramView-Dq-d0f14.js} +1 -1
- package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +1 -0
- package/dist/assets/MarkdownRenderer-E207cTy6.js +2 -0
- package/dist/assets/MarkdownRendererImpl-Cr-WDc05.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-Cl2KVPKv.js +3 -0
- package/dist/assets/MarkstreamRendererImpl-DKhVy3nF.css +1 -0
- package/dist/assets/{MobileDetailNavigation-DD29ENgU.js → MobileDetailNavigation-CZ6KcrPL.js} +1 -1
- package/dist/assets/{MobileShareBridge-CrTe_QND.js → MobileShareBridge-DQ2cL0Bs.js} +1 -1
- package/dist/assets/MobileSurface-E6GzOicA.js +2 -0
- package/dist/assets/{MultiRunWindow-jG5dCEMH.js → MultiRunWindow-CM_Zipu0.js} +1 -1
- package/dist/assets/{SettingsView-UKlEewdq.js → SettingsView-C71_t48B.js} +1 -1
- package/dist/assets/{SettingsWindow-DO1aAplT.js → SettingsWindow-DY06QLS6.js} +1 -1
- package/dist/assets/{TerminalView-BG1UPLsz.js → TerminalView-CgvaxAlw.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CWr5eVTK.js → ToolOutputDialog-BWodV7Tg.js} +2 -2
- package/dist/assets/{appThemeRegistry-BI5drVpC.js → appThemeRegistry-CinNVI2W.js} +1 -1
- package/dist/assets/{gitApi-D9hFlE3T.js → gitApi-DcPpqAhU.js} +1 -1
- package/dist/assets/{insertionBoundaries-D2L7D0Oe.js → insertionBoundaries-DnE-UyRG.js} +1 -1
- package/dist/assets/{main-BRedOs_V.js → main-BjguLWwd.js} +13 -13
- package/dist/assets/{main-DlXpJ0Vw.js → main-XOb8CpDU.js} +2 -2
- package/dist/assets/markdown-worker-CTsrtpPh.js +2 -0
- package/dist/assets/{MarkdownLoadingSkeleton-DWs4Jg7b.js → markdownHeightCache-CYDlisxr.js} +1 -1
- package/dist/assets/markdownTheme-DyJ4Gfcj.js +2 -0
- package/dist/assets/{miniChat-CBLbFpfy.js → miniChat-Dnbb5G-b.js} +2 -2
- package/dist/assets/{mobile-CoZsWdWk.js → mobile-C7SindTf.js} +2 -2
- package/dist/assets/{multirun-BF-I0r2_.js → multirun-B7Kutn9Z.js} +1 -1
- package/dist/assets/{projectMeta-2t4L5_4a.js → projectMeta-B4lfdlyn.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-fl7O3iHy.js → renderElectronMiniChatApp-BL7dbwiC.js} +2 -2
- package/dist/assets/renderMobileApp-Dm8AXwBj.js +28 -0
- package/dist/assets/{runtimeConfig-B4eb4ZiU.js → runtimeConfig-BeO4e7XN.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BHuTYyzc.js → runtimeEndpointReset-CipIZVh0.js} +6 -6
- package/dist/assets/sessionChangeSummary-hVby3sJk.js +1 -0
- package/dist/assets/{sessionLookup-DNc0ZeR3.js → sessionLookup-DyRYhncr.js} +1 -1
- package/dist/assets/{src-DasvfkEg.css → src-B-P5qIBc.css} +1 -1
- package/dist/assets/{useAppFontEffects-Dle8xs5h.js → useAppFontEffects-Bwls3ufZ.js} +44 -44
- package/dist/assets/{useDesktopWindowControlsLayout-_QAwKcQn.js → useDesktopWindowControlsLayout-Bl8iSMoM.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BSyuAgz6.js → useEffectiveDirectory-Co4VOSLW.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-f8xHJk7C.js → useMobileNavigationStore-CDx-mkjA.js} +2 -2
- package/dist/assets/{useSessionAutoCleanup-BHiXaW0J.js → useSessionAutoCleanup-BR11Ewfa.js} +1 -1
- package/dist/assets/useWorkerHighlightedLines-MhxFZaDl.js +7 -0
- package/dist/assets/{vendor-markstream-react-C3K8YH0d.js → vendor-markstream-react-DX55XLGO.js} +4 -4
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +3 -3
- package/server/lib/assistants/contact-tools.js +7 -4
- package/server/lib/assistants/contact-tools.test.js +12 -3
- package/server/lib/assistants/harness.js +117 -93
- package/server/lib/assistants/harness.test.js +90 -0
- package/server/lib/assistants/pi-tools.js +118 -0
- package/server/lib/assistants/pi-tools.test.js +114 -0
- package/server/lib/llm/DOCUMENTATION.md +9 -4
- package/server/lib/llm/generate.test.js +1 -0
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/runtime.js +9 -2
- package/server/lib/notifications/runtime.test.js +144 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -2
- package/server/lib/session-index/service.js +1 -0
- package/server/lib/session-index/service.test.js +7 -2
- package/server/lib/session-title/DOCUMENTATION.md +4 -3
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +6 -0
- package/dist/assets/AssistantView-nVF0kdFh.js +0 -2
- package/dist/assets/FadeInOnReveal-CK4asqVR.js +0 -1
- package/dist/assets/MarkdownRenderer-DT_GN1Ex.js +0 -2
- package/dist/assets/MarkdownRendererImpl-Cslh4XN_.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +0 -1
- package/dist/assets/MarkstreamRendererImpl-Ymm12x6J.js +0 -1
- package/dist/assets/MobileSurface-DjxchpUC.js +0 -2
- package/dist/assets/fileReferenceActions-BJJtX_S5.js +0 -1
- package/dist/assets/markdown-worker-BuveaH3O.js +0 -1
- package/dist/assets/markdownHeightCache-CvUyZT4q.js +0 -1
- package/dist/assets/markdownTheme-CzZm3riB.js +0 -2
- package/dist/assets/outsideFileGrants-DnplRxr-.js +0 -1
- package/dist/assets/path-utils-jGozTg9d.js +0 -1
- package/dist/assets/renderMobileApp-CKYDwf4U.js +0 -28
- package/dist/assets/sessionChangeSummary-DOq8EN_H.js +0 -1
- package/dist/assets/toolHelpers-DEvVP9U0.js +0 -2
- package/dist/assets/useWorkerHighlightedLines-DuvXTSm_.js +0 -7
- package/dist/assets/vendor-markstream-react-CGQrpq8a.css +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
createBashTool,
|
|
5
|
+
createEditTool,
|
|
6
|
+
createReadTool,
|
|
7
|
+
createWriteTool,
|
|
8
|
+
formatSkillsForSystemPrompt,
|
|
9
|
+
loadSkills,
|
|
10
|
+
} from '@earendil-works/pi-agent-core';
|
|
11
|
+
import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node';
|
|
12
|
+
|
|
13
|
+
const EXTERNAL_SKILL_ROOTS = Object.freeze(['.claude', '.agents']);
|
|
14
|
+
|
|
15
|
+
export const PI_CODING_TOOL_NAMES = Object.freeze(['read', 'write', 'edit', 'bash']);
|
|
16
|
+
const PI_CODING_TOOL_NAME_SET = new Set(PI_CODING_TOOL_NAMES);
|
|
17
|
+
|
|
18
|
+
export function isPiCodingToolName(name) {
|
|
19
|
+
return typeof name === 'string' && PI_CODING_TOOL_NAME_SET.has(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveAssistantCwd(assistant) {
|
|
23
|
+
for (const value of [assistant?.effectiveWorkspacePath, assistant?.workspacePath]) {
|
|
24
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Global ~/.claude/skills + ~/.agents/skills, then this project cwd (.claude then .agents). Later dirs win on name. */
|
|
30
|
+
export function collectAssistantSkillDirectories(cwd, { homeDir = os.homedir() } = {}) {
|
|
31
|
+
const dirs = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const push = (dir) => {
|
|
34
|
+
if (typeof dir !== 'string' || !dir.trim()) return;
|
|
35
|
+
const resolved = path.resolve(dir);
|
|
36
|
+
if (seen.has(resolved)) return;
|
|
37
|
+
seen.add(resolved);
|
|
38
|
+
dirs.push(resolved);
|
|
39
|
+
};
|
|
40
|
+
const home = typeof homeDir === 'string' && homeDir.trim() ? path.resolve(homeDir.trim()) : os.homedir();
|
|
41
|
+
for (const root of EXTERNAL_SKILL_ROOTS) {
|
|
42
|
+
push(path.join(home, root, 'skills'));
|
|
43
|
+
}
|
|
44
|
+
if (typeof cwd === 'string' && cwd.trim()) {
|
|
45
|
+
const project = path.resolve(cwd.trim());
|
|
46
|
+
for (const root of EXTERNAL_SKILL_ROOTS) {
|
|
47
|
+
push(path.join(project, root, 'skills'));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return dirs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mergeSkillsByName(skills) {
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const skill of Array.isArray(skills) ? skills : []) {
|
|
56
|
+
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
|
|
57
|
+
if (!name) continue;
|
|
58
|
+
map.set(name, skill);
|
|
59
|
+
}
|
|
60
|
+
return [...map.values()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bindHarnessTool = (tool, context) => ({
|
|
64
|
+
name: tool.name,
|
|
65
|
+
label: tool.label,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
parameters: tool.parameters,
|
|
68
|
+
execute: (toolCallId, params, signal, onUpdate) => (
|
|
69
|
+
tool.execute(toolCallId, params, signal, onUpdate, context)
|
|
70
|
+
),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export function formatPiCodingPrompt({ cwd, skillsPrompt = '' } = {}) {
|
|
74
|
+
if (typeof cwd !== 'string' || !cwd.trim()) return '';
|
|
75
|
+
return [
|
|
76
|
+
`Working directory: ${cwd}`,
|
|
77
|
+
'You have pi coding tools in this directory: read, write, edit, bash.',
|
|
78
|
+
'This is your working directory. Ignore any other cwd from the environment, including temporary generator workspaces under /var/folders or os.tmpdir.',
|
|
79
|
+
'When the user asks pwd, the current directory, or to look at files, call bash/read yourself. Never say you have no terminal or cannot read or write files.',
|
|
80
|
+
'Skills are merged from ~/.claude/skills, ~/.agents/skills, and this project\'s .claude/skills plus .agents/skills (project wins on name). Read a skill file when the task matches its description.',
|
|
81
|
+
'Call these tools with the same fenced JSON as other tools:',
|
|
82
|
+
'```openchamber-tool',
|
|
83
|
+
'{"name":"bash","arguments":{"command":"pwd"}}',
|
|
84
|
+
'```',
|
|
85
|
+
'```openchamber-tool',
|
|
86
|
+
'{"name":"read","arguments":{"path":".agents/skills/example/SKILL.md"}}',
|
|
87
|
+
'```',
|
|
88
|
+
typeof skillsPrompt === 'string' && skillsPrompt.trim() ? skillsPrompt.trim() : '',
|
|
89
|
+
].filter(Boolean).join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function createPiCodingRuntime(cwd, { homeDir = os.homedir() } = {}) {
|
|
93
|
+
if (typeof cwd !== 'string' || !cwd.trim()) return null;
|
|
94
|
+
const resolved = cwd.trim();
|
|
95
|
+
const env = new NodeExecutionEnv({ cwd: resolved });
|
|
96
|
+
const context = { env };
|
|
97
|
+
const tools = [
|
|
98
|
+
bindHarnessTool(createReadTool(), context),
|
|
99
|
+
bindHarnessTool(createWriteTool(), context),
|
|
100
|
+
bindHarnessTool(createEditTool(), context),
|
|
101
|
+
bindHarnessTool(createBashTool(), context),
|
|
102
|
+
];
|
|
103
|
+
let skillsPrompt = '';
|
|
104
|
+
try {
|
|
105
|
+
const loaded = await loadSkills(env, collectAssistantSkillDirectories(resolved, { homeDir }));
|
|
106
|
+
skillsPrompt = formatSkillsForSystemPrompt(mergeSkillsByName(loaded?.skills));
|
|
107
|
+
} catch {
|
|
108
|
+
skillsPrompt = '';
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
cwd: resolved,
|
|
112
|
+
tools,
|
|
113
|
+
skillsPrompt,
|
|
114
|
+
close: async () => {
|
|
115
|
+
await env.cleanup();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
collectAssistantSkillDirectories,
|
|
7
|
+
createPiCodingRuntime,
|
|
8
|
+
mergeSkillsByName,
|
|
9
|
+
PI_CODING_TOOL_NAMES,
|
|
10
|
+
resolveAssistantCwd,
|
|
11
|
+
} from './pi-tools.js';
|
|
12
|
+
|
|
13
|
+
const temps = [];
|
|
14
|
+
const temp = () => {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-pi-tools-'));
|
|
16
|
+
temps.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
};
|
|
19
|
+
const writeSkill = (root, name, description = `${name} skill`) => {
|
|
20
|
+
const dir = path.join(root, name);
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\nInstructions for ${name}\n`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
while (temps.length > 0) {
|
|
27
|
+
fs.rmSync(temps.pop(), { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('resolveAssistantCwd', () => {
|
|
32
|
+
it('prefers effectiveWorkspacePath over workspacePath', () => {
|
|
33
|
+
expect(resolveAssistantCwd({
|
|
34
|
+
effectiveWorkspacePath: '/effective',
|
|
35
|
+
workspacePath: '/workspace',
|
|
36
|
+
})).toBe('/effective');
|
|
37
|
+
expect(resolveAssistantCwd({ workspacePath: '/workspace' })).toBe('/workspace');
|
|
38
|
+
expect(resolveAssistantCwd({ defaultPrompt: 'x' })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('collectAssistantSkillDirectories', () => {
|
|
43
|
+
it('lists global ~/.claude and ~/.agents then project .claude and .agents', () => {
|
|
44
|
+
const home = temp();
|
|
45
|
+
const cwd = temp();
|
|
46
|
+
expect(collectAssistantSkillDirectories(cwd, { homeDir: home })).toEqual([
|
|
47
|
+
path.resolve(home, '.claude', 'skills'),
|
|
48
|
+
path.resolve(home, '.agents', 'skills'),
|
|
49
|
+
path.resolve(cwd, '.claude', 'skills'),
|
|
50
|
+
path.resolve(cwd, '.agents', 'skills'),
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not invent skill roots on intermediate parent folders', () => {
|
|
55
|
+
const home = temp();
|
|
56
|
+
const root = temp();
|
|
57
|
+
const nested = path.join(root, 'apps', 'web');
|
|
58
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
59
|
+
expect(collectAssistantSkillDirectories(nested, { homeDir: home })).toEqual([
|
|
60
|
+
path.resolve(home, '.claude', 'skills'),
|
|
61
|
+
path.resolve(home, '.agents', 'skills'),
|
|
62
|
+
path.resolve(nested, '.claude', 'skills'),
|
|
63
|
+
path.resolve(nested, '.agents', 'skills'),
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('mergeSkillsByName', () => {
|
|
69
|
+
it('keeps the last skill with the same name', () => {
|
|
70
|
+
expect(mergeSkillsByName([
|
|
71
|
+
{ name: 'shared', filePath: '/global' },
|
|
72
|
+
{ name: 'shared', filePath: '/project' },
|
|
73
|
+
{ name: 'other', filePath: '/other' },
|
|
74
|
+
])).toEqual([
|
|
75
|
+
{ name: 'shared', filePath: '/project' },
|
|
76
|
+
{ name: 'other', filePath: '/other' },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('createPiCodingRuntime', () => {
|
|
82
|
+
it('installs read/write/edit/bash and merges global plus project skills', async () => {
|
|
83
|
+
const home = temp();
|
|
84
|
+
const cwd = temp();
|
|
85
|
+
writeSkill(path.join(home, '.claude', 'skills'), 'claude-global', 'Claude global');
|
|
86
|
+
writeSkill(path.join(home, '.agents', 'skills'), 'agents-global', 'Agents global');
|
|
87
|
+
writeSkill(path.join(cwd, '.claude', 'skills'), 'claude-project', 'Claude project');
|
|
88
|
+
writeSkill(path.join(cwd, '.agents', 'skills'), 'project-skill', 'Project skill');
|
|
89
|
+
writeSkill(path.join(home, '.agents', 'skills'), 'shared', 'Global shared');
|
|
90
|
+
writeSkill(path.join(cwd, '.agents', 'skills'), 'shared', 'Project shared');
|
|
91
|
+
|
|
92
|
+
const runtime = await createPiCodingRuntime(cwd, { homeDir: home });
|
|
93
|
+
expect(runtime.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES]);
|
|
94
|
+
expect(runtime.skillsPrompt).toContain('claude-global');
|
|
95
|
+
expect(runtime.skillsPrompt).toContain('agents-global');
|
|
96
|
+
expect(runtime.skillsPrompt).toContain('claude-project');
|
|
97
|
+
expect(runtime.skillsPrompt).toContain('project-skill');
|
|
98
|
+
expect(runtime.skillsPrompt).toContain('Project shared');
|
|
99
|
+
expect(runtime.skillsPrompt).not.toContain('Global shared');
|
|
100
|
+
await runtime.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('runs bash pwd in the workspace', async () => {
|
|
104
|
+
const cwd = temp();
|
|
105
|
+
const home = temp();
|
|
106
|
+
const runtime = await createPiCodingRuntime(cwd, { homeDir: home });
|
|
107
|
+
const bash = runtime.tools.find((tool) => tool.name === 'bash');
|
|
108
|
+
const result = await bash.execute('call_pwd', { command: 'pwd' });
|
|
109
|
+
const text = result.content.map((part) => part.text).join('');
|
|
110
|
+
const resolved = fs.realpathSync(cwd);
|
|
111
|
+
expect(text.includes(cwd) || text.includes(resolved)).toBe(true);
|
|
112
|
+
await runtime.close();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -41,10 +41,15 @@ The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
|
|
|
41
41
|
Non-vision models (for example deepseek-v4-flash) keep the `[image: …]`
|
|
42
42
|
description and any text-file bytes, and skip image data URLs so generate
|
|
43
43
|
cannot stall on unsupported vision parts. Non-image
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
text files are also inlined into the flattened prompt. Wait for idle via
|
|
45
|
+
`session.status` + `session.messages`, then delete the session. Throwaway
|
|
46
|
+
sessions are created with
|
|
47
|
+
`metadata.openchamber.llm.purpose = 'chat-completions'` (and archived
|
|
48
|
+
immediately) so sidebar visibility, session-index, session-title, and
|
|
49
|
+
notification/push fanout treat them as system-owned — same contract as
|
|
50
|
+
non-empty `smallModel.purpose`. This is a text generator only — never the
|
|
51
|
+
contact transcript and never a coding SessionPrompt loop. Upstream
|
|
52
|
+
`info.error.message` is forwarded on 502.
|
|
48
53
|
|
|
49
54
|
### Internal token callback (in-process only)
|
|
50
55
|
|
|
@@ -78,6 +78,7 @@ describe('generateOpenCodeText', () => {
|
|
|
78
78
|
|
|
79
79
|
expect(create).toHaveBeenCalledWith(expect.objectContaining({
|
|
80
80
|
title: expect.stringContaining('[openchamber-llm]'),
|
|
81
|
+
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
81
82
|
}), expect.anything())
|
|
82
83
|
expect(update).toHaveBeenCalled()
|
|
83
84
|
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -47,7 +47,7 @@ This module provides notification message preparation utilities for the web serv
|
|
|
47
47
|
- Returned API:
|
|
48
48
|
- `maybeSendPushForTrigger(payload)`
|
|
49
49
|
- Owns:
|
|
50
|
-
- top-level completion/question/permission trigger routing; completion is the sole task-status notification. Ordinary push is limited to sidebar-visible root sessions. Child/subagent sessions, Assistant bindings (`openchamber.assistant.assistantID` unless `assigned.from === 'contact'`), scheduled-task sessions, small-model system sessions (`metadata.openchamber.smallModel.purpose`), and `smartfetch-secondary` titles are suppressed. Contact-assigned workers stay visible and still notify.
|
|
50
|
+
- top-level completion/question/permission trigger routing; completion is the sole task-status notification. Ordinary push is limited to sidebar-visible root sessions. Child/subagent sessions, Assistant bindings (`openchamber.assistant.assistantID` unless `assigned.from === 'contact'`), scheduled-task sessions, small-model system sessions (`metadata.openchamber.smallModel.purpose`), LLM gateway throwaway sessions (`metadata.openchamber.llm.purpose`), and `smartfetch-secondary` titles are suppressed. Contact-assigned workers stay visible and still notify.
|
|
51
51
|
- contact-turn notifications (`sendContactTurnNotification`) are a separate SMS-style path: title is the assistant nickname, body is the spoken message text, on desktop / UI SSE / web-push / APNs. APNs does **not** rewrite these into the generic "Task completed" scenario title.
|
|
52
52
|
- top-level completion/error Live Activity `end` (`sendLiveActivityEnd`), independent of ordinary push settings and UI visibility, with the same hidden-session suppression; duplicate terminal events are idempotent at the token store
|
|
53
53
|
- session meta cache for sidebar-visibility suppression
|
|
@@ -185,6 +185,9 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
185
185
|
smallModelPurpose: meta.smallModelPurpose !== undefined
|
|
186
186
|
? meta.smallModelPurpose
|
|
187
187
|
: (previous?.smallModelPurpose ?? null),
|
|
188
|
+
llmPurpose: meta.llmPurpose !== undefined
|
|
189
|
+
? meta.llmPurpose
|
|
190
|
+
: (previous?.llmPurpose ?? null),
|
|
188
191
|
assistantID: meta.assistantID !== undefined ? meta.assistantID : (previous?.assistantID ?? null),
|
|
189
192
|
assignedFrom: meta.assignedFrom !== undefined ? meta.assignedFrom : (previous?.assignedFrom ?? null),
|
|
190
193
|
scheduledTaskID: meta.scheduledTaskID !== undefined
|
|
@@ -202,6 +205,9 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
202
205
|
: null;
|
|
203
206
|
return {
|
|
204
207
|
smallModelPurpose: normalizeSmallModelPurpose(openchamber?.smallModel?.purpose),
|
|
208
|
+
// Throwaway LLM gateway sessions (`metadata.openchamber.llm.purpose`) share
|
|
209
|
+
// the same sidebar-hidden contract as small-model system sessions.
|
|
210
|
+
llmPurpose: normalizeSmallModelPurpose(openchamber?.llm?.purpose),
|
|
205
211
|
assistantID: nonEmptySystemID(openchamber?.assistant?.assistantID)
|
|
206
212
|
? openchamber.assistant.assistantID
|
|
207
213
|
: null,
|
|
@@ -292,6 +298,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
292
298
|
if (!meta) return false;
|
|
293
299
|
if (typeof meta.parentID === 'string' && meta.parentID.length > 0) return true;
|
|
294
300
|
if (typeof meta.smallModelPurpose === 'string' && meta.smallModelPurpose.length > 0) return true;
|
|
301
|
+
if (typeof meta.llmPurpose === 'string' && meta.llmPurpose.length > 0) return true;
|
|
295
302
|
if (typeof meta.assistantID === 'string' && meta.assistantID.length > 0 && meta.assignedFrom !== 'contact') {
|
|
296
303
|
return true;
|
|
297
304
|
}
|
|
@@ -302,8 +309,8 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
302
309
|
|
|
303
310
|
// Only sessions that can appear as sidebar roots get ordinary push. Child /
|
|
304
311
|
// subagent sessions, archived Assistant bindings, scheduled-task sessions,
|
|
305
|
-
// small-model system sessions, and SmartFetch secondaries are
|
|
306
|
-
// Contact-assigned worker sessions stay visible and still notify.
|
|
312
|
+
// small-model / LLM gateway system sessions, and SmartFetch secondaries are
|
|
313
|
+
// excluded. Contact-assigned worker sessions stay visible and still notify.
|
|
307
314
|
const shouldSkipSystemSessionNotification = async (sessionId, directory) => {
|
|
308
315
|
const meta = await fetchSessionMeta(sessionId, directory);
|
|
309
316
|
return isHiddenFromNavSession(meta);
|
|
@@ -229,6 +229,150 @@ describe('notification trigger runtime smallModel suppression', () => {
|
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
describe('notification trigger runtime llm system-session suppression', () => {
|
|
233
|
+
const llmSessionResponse = (purpose = 'chat-completions') => jsonResponse({
|
|
234
|
+
id: 'ses_llm',
|
|
235
|
+
parentID: null,
|
|
236
|
+
title: '[openchamber-llm] generate',
|
|
237
|
+
metadata: { openchamber: { llm: { purpose } } },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const expectAllOutletsSilent = ({
|
|
241
|
+
emitDesktopNotification,
|
|
242
|
+
broadcastUiNotification,
|
|
243
|
+
sendPushToAllUiSessions,
|
|
244
|
+
sendApnsToAllUiSessions,
|
|
245
|
+
sendLiveActivityEnd,
|
|
246
|
+
}) => {
|
|
247
|
+
expect(emitDesktopNotification).not.toHaveBeenCalled();
|
|
248
|
+
expect(broadcastUiNotification).not.toHaveBeenCalled();
|
|
249
|
+
expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
|
|
250
|
+
expect(sendApnsToAllUiSessions).not.toHaveBeenCalled();
|
|
251
|
+
expect(sendLiveActivityEnd).not.toHaveBeenCalled();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
it('skips ready notifications for llm sessions fetched by id', async () => {
|
|
255
|
+
vi.stubGlobal('fetch', vi.fn(async () => llmSessionResponse()));
|
|
256
|
+
const outlets = createRuntime();
|
|
257
|
+
await outlets.runtime.maybeSendPushForTrigger(completionPayload('ses_llm'));
|
|
258
|
+
expectAllOutletsSilent(outlets);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('skips ready notifications synthesized from session.idle for llm sessions', async () => {
|
|
262
|
+
vi.stubGlobal('fetch', vi.fn(async () => llmSessionResponse()));
|
|
263
|
+
const outlets = createRuntime();
|
|
264
|
+
await outlets.runtime.maybeSendPushForTrigger({
|
|
265
|
+
type: 'session.idle',
|
|
266
|
+
properties: {
|
|
267
|
+
directory: '/repo',
|
|
268
|
+
sessionID: 'ses_llm',
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
expectAllOutletsSilent(outlets);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('skips error notifications and live-activity end for llm sessions', async () => {
|
|
275
|
+
vi.stubGlobal('fetch', vi.fn(async () => llmSessionResponse()));
|
|
276
|
+
const outlets = createRuntime();
|
|
277
|
+
await outlets.runtime.maybeSendPushForTrigger({
|
|
278
|
+
type: 'session.error',
|
|
279
|
+
properties: {
|
|
280
|
+
directory: '/repo',
|
|
281
|
+
sessionID: 'ses_llm',
|
|
282
|
+
error: 'boom',
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
expectAllOutletsSilent(outlets);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('skips question notifications for llm sessions', async () => {
|
|
289
|
+
vi.useFakeTimers();
|
|
290
|
+
vi.stubGlobal('fetch', vi.fn(async () => llmSessionResponse()));
|
|
291
|
+
const outlets = createRuntime();
|
|
292
|
+
await outlets.runtime.maybeSendPushForTrigger({
|
|
293
|
+
type: 'question.asked',
|
|
294
|
+
properties: {
|
|
295
|
+
directory: '/repo',
|
|
296
|
+
sessionID: 'ses_llm',
|
|
297
|
+
questions: [{ header: 'Input needed', question: 'Continue?' }],
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
await vi.runAllTimersAsync();
|
|
301
|
+
expectAllOutletsSilent(outlets);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('skips permission notifications for llm sessions', async () => {
|
|
305
|
+
vi.useFakeTimers();
|
|
306
|
+
vi.stubGlobal('fetch', vi.fn(async () => llmSessionResponse()));
|
|
307
|
+
const outlets = createRuntime();
|
|
308
|
+
await outlets.runtime.maybeSendPushForTrigger({
|
|
309
|
+
type: 'permission.asked',
|
|
310
|
+
properties: {
|
|
311
|
+
directory: '/repo',
|
|
312
|
+
sessionID: 'ses_llm',
|
|
313
|
+
id: 'perm_llm',
|
|
314
|
+
permission: 'edit',
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
await vi.runAllTimersAsync();
|
|
318
|
+
expectAllOutletsSilent(outlets);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('uses session.updated event cache so later idle stays suppressed without llm on fetch', async () => {
|
|
322
|
+
const fetchMock = vi.fn(async () => jsonResponse({
|
|
323
|
+
id: 'ses_llm',
|
|
324
|
+
parentID: null,
|
|
325
|
+
title: 'Ordinary after strip',
|
|
326
|
+
metadata: {},
|
|
327
|
+
}));
|
|
328
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
329
|
+
const outlets = createRuntime();
|
|
330
|
+
await outlets.runtime.maybeSendPushForTrigger({
|
|
331
|
+
type: 'session.updated',
|
|
332
|
+
properties: {
|
|
333
|
+
directory: '/repo',
|
|
334
|
+
info: {
|
|
335
|
+
id: 'ses_llm',
|
|
336
|
+
sessionID: 'ses_llm',
|
|
337
|
+
parentID: null,
|
|
338
|
+
title: '[openchamber-llm] generate',
|
|
339
|
+
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
await outlets.runtime.maybeSendPushForTrigger({
|
|
344
|
+
type: 'session.idle',
|
|
345
|
+
properties: {
|
|
346
|
+
directory: '/repo',
|
|
347
|
+
sessionID: 'ses_llm',
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
351
|
+
expectAllOutletsSilent(outlets);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('does not treat empty llm.purpose as a hidden system session', async () => {
|
|
355
|
+
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
|
|
356
|
+
id: 'ses_llm_empty',
|
|
357
|
+
parentID: null,
|
|
358
|
+
title: 'Looks like llm',
|
|
359
|
+
metadata: { openchamber: { llm: { purpose: '' } } },
|
|
360
|
+
})));
|
|
361
|
+
const {
|
|
362
|
+
runtime,
|
|
363
|
+
emitDesktopNotification,
|
|
364
|
+
sendPushToAllUiSessions,
|
|
365
|
+
sendApnsToAllUiSessions,
|
|
366
|
+
sendLiveActivityEnd,
|
|
367
|
+
} = createRuntime();
|
|
368
|
+
await runtime.maybeSendPushForTrigger(completionPayload('ses_llm_empty'));
|
|
369
|
+
expect(emitDesktopNotification).toHaveBeenCalledTimes(1);
|
|
370
|
+
expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
|
|
371
|
+
expect(sendApnsToAllUiSessions).toHaveBeenCalledTimes(1);
|
|
372
|
+
expect(sendLiveActivityEnd).toHaveBeenCalledWith({ sessionId: 'ses_llm_empty', status: 'complete' });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
232
376
|
const rootSessionResponse = () => jsonResponse({
|
|
233
377
|
id: 'ses_root',
|
|
234
378
|
parentID: null,
|
|
@@ -18,8 +18,9 @@ the index excludes them from every snapshot and clears prior summaries when a
|
|
|
18
18
|
`metadata.openchamber.assistant.assistantID` (unless
|
|
19
19
|
`metadata.openchamber.assigned.from === 'contact'` — those are visible
|
|
20
20
|
coding workers the Assistant subscribed to),
|
|
21
|
-
`metadata.openchamber.scheduledTask.taskID`,
|
|
22
|
-
`metadata.openchamber.smallModel.purpose
|
|
21
|
+
`metadata.openchamber.scheduledTask.taskID`,
|
|
22
|
+
`metadata.openchamber.smallModel.purpose`, or
|
|
23
|
+
`metadata.openchamber.llm.purpose`. Title prefixes are human labels and
|
|
23
24
|
never participate in this filter. Metadata is the ownership/isolation signal;
|
|
24
25
|
`time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
|
|
25
26
|
overlaid from the independent `session_pin` table (also mirrored onto in-window
|
|
@@ -20,6 +20,7 @@ const isSystemSession = (session) => {
|
|
|
20
20
|
if (nonEmptySystemID(openchamber.assistant?.assistantID) && !isContactAssignedSession(openchamber)) return true;
|
|
21
21
|
if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
|
|
22
22
|
if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
|
|
23
|
+
if (nonEmptySystemID(openchamber.llm?.purpose)) return true;
|
|
23
24
|
return false;
|
|
24
25
|
};
|
|
25
26
|
|
|
@@ -68,7 +68,7 @@ describe('Electron session index', () => {
|
|
|
68
68
|
service.close();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it('excludes Assistant, Scheduled, and
|
|
71
|
+
it('excludes Assistant, Scheduled, smallModel, and llm system sessions from ordinary sidebar summaries', () => {
|
|
72
72
|
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
73
73
|
const service = createService(runtimeRef);
|
|
74
74
|
const assistantSession = {
|
|
@@ -83,11 +83,15 @@ describe('Electron session index', () => {
|
|
|
83
83
|
...session('ses_small_model', 97),
|
|
84
84
|
metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
|
|
85
85
|
};
|
|
86
|
+
const llmSession = {
|
|
87
|
+
...session('ses_llm', 96),
|
|
88
|
+
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
89
|
+
};
|
|
86
90
|
const ordinary = session('ses_ordinary', 98);
|
|
87
91
|
|
|
88
92
|
service.replaceDirectory({
|
|
89
93
|
directory: '/repo',
|
|
90
|
-
sessions: [assistantSession, scheduledSession, smallModelSession, ordinary],
|
|
94
|
+
sessions: [assistantSession, scheduledSession, smallModelSession, llmSession, ordinary],
|
|
91
95
|
cursor: null,
|
|
92
96
|
hasMore: false,
|
|
93
97
|
});
|
|
@@ -96,6 +100,7 @@ describe('Electron session index', () => {
|
|
|
96
100
|
service.upsert(assistantSession);
|
|
97
101
|
service.upsert(scheduledSession);
|
|
98
102
|
service.upsert(smallModelSession);
|
|
103
|
+
service.upsert(llmSession);
|
|
99
104
|
expect(service.snapshot().directories[0].sessions.map((item) => item.id)).toEqual(['ses_ordinary']);
|
|
100
105
|
service.close();
|
|
101
106
|
});
|
|
@@ -35,9 +35,10 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
|
|
|
35
35
|
3. On fire:
|
|
36
36
|
- Skip when Settings → Chat → session title refresh is off.
|
|
37
37
|
- Skip system-owned sessions whose metadata carries a non-empty
|
|
38
|
-
`openchamber.assistant.assistantID`, `openchamber.scheduledTask.taskID`,
|
|
39
|
-
`openchamber.smallModel.purpose`
|
|
40
|
-
(fixed Assistant / scheduled / small-model titles must not be
|
|
38
|
+
`openchamber.assistant.assistantID`, `openchamber.scheduledTask.taskID`,
|
|
39
|
+
`openchamber.smallModel.purpose`, or `openchamber.llm.purpose`
|
|
40
|
+
(fixed Assistant / scheduled / small-model / LLM gateway titles must not be
|
|
41
|
+
rewritten). Early
|
|
41
42
|
`session.created` and smart-title request extraction skip the same
|
|
42
43
|
metadata when present so timers are not armed.
|
|
43
44
|
- Skip sub-agent sessions (`parentID`), multi-run/fusion structural titles,
|
|
@@ -167,6 +167,7 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
|
|
|
167
167
|
if (typeof openchamber.assistant?.assistantID === 'string' && openchamber.assistant.assistantID) return true;
|
|
168
168
|
if (typeof openchamber.scheduledTask?.taskID === 'string' && openchamber.scheduledTask.taskID) return true;
|
|
169
169
|
if (typeof openchamber.smallModel?.purpose === 'string' && openchamber.smallModel.purpose) return true;
|
|
170
|
+
if (typeof openchamber.llm?.purpose === 'string' && openchamber.llm.purpose) return true;
|
|
170
171
|
return false;
|
|
171
172
|
};
|
|
172
173
|
|
|
@@ -235,6 +235,9 @@ describe('session-title helpers', () => {
|
|
|
235
235
|
expect(isSystemOwnedSession({
|
|
236
236
|
metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
|
|
237
237
|
})).toBe(true);
|
|
238
|
+
expect(isSystemOwnedSession({
|
|
239
|
+
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
240
|
+
})).toBe(true);
|
|
238
241
|
expect(isSystemOwnedSession({
|
|
239
242
|
title: '[Assistant] Looks system',
|
|
240
243
|
metadata: { openchamber: { assistant: { name: 'no-id' } } },
|
|
@@ -242,6 +245,9 @@ describe('session-title helpers', () => {
|
|
|
242
245
|
expect(isSystemOwnedSession({
|
|
243
246
|
metadata: { openchamber: { smallModel: { purpose: '' } } },
|
|
244
247
|
})).toBe(false);
|
|
248
|
+
expect(isSystemOwnedSession({
|
|
249
|
+
metadata: { openchamber: { llm: { purpose: '' } } },
|
|
250
|
+
})).toBe(false);
|
|
245
251
|
expect(isSystemOwnedSession({ title: 'Ordinary' })).toBe(false);
|
|
246
252
|
expect(isSystemOwnedSession({
|
|
247
253
|
metadata: { openchamber: { assigned: { from: 'contact', assistantID: 'assistant_1' } } },
|