amalgm 0.1.135 → 0.1.136
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/package.json +1 -1
- package/runtime/lib/harnesses.js +61 -162
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
- package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
- package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
- package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
- package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
- package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
- package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
- package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
- package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
- package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
- package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
- package/runtime/scripts/chat-core/adapters/pi.js +302 -0
- package/runtime/scripts/chat-core/auth.js +20 -3
- package/runtime/scripts/chat-core/chat-payload.js +2 -0
- package/runtime/scripts/chat-core/contract.js +56 -1
- package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
- package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
- package/runtime/scripts/chat-core/server.js +4 -0
- package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
- package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
- package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
- package/runtime/scripts/chat-core/usage.js +20 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
compactionFinished,
|
|
5
|
+
compactionStarted,
|
|
6
|
+
errorEvent,
|
|
7
|
+
reasoningDelta,
|
|
8
|
+
textDelta,
|
|
9
|
+
toolCompleted,
|
|
10
|
+
toolStarted,
|
|
11
|
+
toolUpdated,
|
|
12
|
+
usageFinal,
|
|
13
|
+
warningEvent,
|
|
14
|
+
} = require('../events');
|
|
15
|
+
|
|
16
|
+
function contentToText(content) {
|
|
17
|
+
if (typeof content === 'string') return content;
|
|
18
|
+
if (!Array.isArray(content)) return '';
|
|
19
|
+
return content
|
|
20
|
+
.map((item) => (item && item.type === 'text' && typeof item.text === 'string' ? item.text : ''))
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// pi AgentMessage.usage ({input, output, cacheRead, cacheWrite, totalTokens,
|
|
26
|
+
// cost.total, contextSize?}) -> exact per-step usage. Pi is the only harness
|
|
27
|
+
// that reports native USD cost per message, so costUsd rides along.
|
|
28
|
+
function piMessageUsage(message, base = {}) {
|
|
29
|
+
const usage = message?.usage;
|
|
30
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
31
|
+
const total = Number(usage.totalTokens);
|
|
32
|
+
const hasTokens = ['input', 'output', 'cacheRead', 'cacheWrite'].some((key) => Number(usage[key]) > 0);
|
|
33
|
+
if (!hasTokens && !(Number.isFinite(total) && total > 0)) return null;
|
|
34
|
+
const contextSize = Number(usage.contextSize);
|
|
35
|
+
return usageFinal({
|
|
36
|
+
inputTokens: usage.input,
|
|
37
|
+
outputTokens: usage.output,
|
|
38
|
+
cacheReadTokens: usage.cacheRead,
|
|
39
|
+
cacheWriteTokens: usage.cacheWrite,
|
|
40
|
+
...(Number.isFinite(contextSize) && contextSize > 0 ? { inputContextSize: contextSize, contextUsedTokens: contextSize } : {}),
|
|
41
|
+
costUsd: Number.isFinite(Number(usage.cost?.total)) ? Number(usage.cost.total) : null,
|
|
42
|
+
operation: 'chat_step',
|
|
43
|
+
billable: true,
|
|
44
|
+
exact: true,
|
|
45
|
+
source: 'pi_rpc_message_usage',
|
|
46
|
+
}, base);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// get_session_stats data -> context-circle snapshot (never billed; the exact
|
|
50
|
+
// per-message records above are the billing source).
|
|
51
|
+
function piSessionStatsUsage(stats, base = {}) {
|
|
52
|
+
const contextUsage = stats?.contextUsage;
|
|
53
|
+
if (!contextUsage || !Number.isFinite(Number(contextUsage.tokens))) return null;
|
|
54
|
+
return usageFinal({
|
|
55
|
+
usedTokens: contextUsage.tokens,
|
|
56
|
+
maxTokens: contextUsage.contextWindow,
|
|
57
|
+
totalProcessedTokens: stats?.tokens?.total,
|
|
58
|
+
costUsd: Number.isFinite(Number(stats?.cost)) ? Number(stats.cost) : null,
|
|
59
|
+
operation: 'context_snapshot',
|
|
60
|
+
billable: false,
|
|
61
|
+
exact: false,
|
|
62
|
+
source: 'pi_rpc_session_stats',
|
|
63
|
+
}, base);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One pi RPC event -> amalgm events. `state` is per-prompt: providerSessionId
|
|
67
|
+
// plus tool bookkeeping (tool_execution_start may race toolcall_end).
|
|
68
|
+
function normalizePiEvent(event, state) {
|
|
69
|
+
const base = { providerSessionId: state.providerSessionId };
|
|
70
|
+
const type = event?.type;
|
|
71
|
+
if (type === 'message_update') {
|
|
72
|
+
const delta = event.assistantMessageEvent || {};
|
|
73
|
+
if (delta.type === 'text_delta' && delta.delta) return [textDelta(delta.delta, base)];
|
|
74
|
+
if (delta.type === 'thinking_delta' && delta.delta) return [reasoningDelta(delta.delta, base)];
|
|
75
|
+
if (delta.type === 'error' && delta.reason !== 'aborted') {
|
|
76
|
+
return [errorEvent(event.message?.errorMessage || event.message?.error || 'Pi model error', base)];
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
if (type === 'message_end') {
|
|
81
|
+
const message = event.message || {};
|
|
82
|
+
if (message.role && message.role !== 'assistant') return [];
|
|
83
|
+
const usage = piMessageUsage(message, base);
|
|
84
|
+
return usage ? [usage] : [];
|
|
85
|
+
}
|
|
86
|
+
if (type === 'tool_execution_start') {
|
|
87
|
+
state.tools.add(event.toolCallId);
|
|
88
|
+
return [toolStarted(event.toolCallId, {
|
|
89
|
+
...base,
|
|
90
|
+
toolName: event.toolName || 'tool',
|
|
91
|
+
input: event.args,
|
|
92
|
+
})];
|
|
93
|
+
}
|
|
94
|
+
if (type === 'tool_execution_update') {
|
|
95
|
+
const text = contentToText(event.partialResult?.content);
|
|
96
|
+
if (!text) return [];
|
|
97
|
+
return [toolUpdated(event.toolCallId, {
|
|
98
|
+
...base,
|
|
99
|
+
toolName: event.toolName || 'tool',
|
|
100
|
+
output: text,
|
|
101
|
+
})];
|
|
102
|
+
}
|
|
103
|
+
if (type === 'tool_execution_end') {
|
|
104
|
+
state.tools.delete(event.toolCallId);
|
|
105
|
+
const output = contentToText(event.result?.content);
|
|
106
|
+
return [toolCompleted(event.toolCallId, {
|
|
107
|
+
...base,
|
|
108
|
+
toolName: event.toolName || 'tool',
|
|
109
|
+
...(output ? { output } : {}),
|
|
110
|
+
isError: event.isError === true,
|
|
111
|
+
...(event.isError === true ? { error: output || 'Tool call failed' } : {}),
|
|
112
|
+
})];
|
|
113
|
+
}
|
|
114
|
+
if (type === 'compaction_start') {
|
|
115
|
+
return [compactionStarted({ ...base, trigger: event.reason === 'manual' ? 'manual' : 'auto' })];
|
|
116
|
+
}
|
|
117
|
+
if (type === 'compaction_end') {
|
|
118
|
+
return [compactionFinished({
|
|
119
|
+
...base,
|
|
120
|
+
trigger: event.reason === 'manual' ? 'manual' : 'auto',
|
|
121
|
+
preTokens: event.result?.tokensBefore,
|
|
122
|
+
})];
|
|
123
|
+
}
|
|
124
|
+
if (type === 'auto_retry_start') {
|
|
125
|
+
return [warningEvent(`Pi is retrying after a transient error${event.error ? `: ${event.error}` : ''}`, base)];
|
|
126
|
+
}
|
|
127
|
+
if (type === 'extension_error') {
|
|
128
|
+
return [warningEvent(`Pi extension error: ${event.error || 'unknown'}`, base)];
|
|
129
|
+
}
|
|
130
|
+
if (type === 'error') {
|
|
131
|
+
return [errorEvent(event.message || event.error || 'Pi agent error', base)];
|
|
132
|
+
}
|
|
133
|
+
// agent_start/agent_end/turn_start/turn_end/message_start/queue_update and
|
|
134
|
+
// toolcall_* deltas are lifecycle noise here; the adapter drives `done` off
|
|
135
|
+
// agent_end so it can attach the final context snapshot first.
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
normalizePiEvent,
|
|
141
|
+
piMessageUsage,
|
|
142
|
+
piSessionStatsUsage,
|
|
143
|
+
};
|
|
@@ -5,7 +5,9 @@ const { PORT, PROXY_BASE_URL, ensureFreshProxyToken } = require('../chat-server/
|
|
|
5
5
|
const db = require('../chat-server/db');
|
|
6
6
|
const { CodexAdapter } = require('./adapters/codex');
|
|
7
7
|
const { ClaudeAdapter } = require('./adapters/claude');
|
|
8
|
+
const { CursorAdapter } = require('./adapters/cursor');
|
|
8
9
|
const { OpenCodeAdapter } = require('./adapters/opencode');
|
|
10
|
+
const { PiAdapter } = require('./adapters/pi');
|
|
9
11
|
const { ChatCore } = require('./engine');
|
|
10
12
|
const { forwardEgress, parseEgressPath } = require('./egress');
|
|
11
13
|
const { forwardMcpRelay, parseMcpRelayPath } = require('./tooling/mcp-relay');
|
|
@@ -60,7 +62,9 @@ function createCore(options = {}) {
|
|
|
60
62
|
adapters: {
|
|
61
63
|
claude_code: () => new ClaudeAdapter(),
|
|
62
64
|
codex: () => new CodexAdapter(),
|
|
65
|
+
cursor: () => new CursorAdapter(),
|
|
63
66
|
opencode: () => new OpenCodeAdapter(),
|
|
67
|
+
pi: () => new PiAdapter(),
|
|
64
68
|
},
|
|
65
69
|
});
|
|
66
70
|
return new ChatCore({ db, runtime, turns, options: { ...options, port: PORT } });
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const { authEnvelope, coerceAuth, runtimeEnv } = require('../auth');
|
|
9
|
+
const { agentToHarness, canonicalModel, cliModelFor } = require('../contract');
|
|
10
|
+
const { cursorPromptUsage, cursorStopReason, normalizeCursorUpdate } = require('../normalizers/cursor');
|
|
11
|
+
const { __private: cursorPrivate } = require('../adapters/cursor');
|
|
12
|
+
const { syncCursorProviderAuth } = require('../tooling/native-config');
|
|
13
|
+
|
|
14
|
+
const { matchAcpModel, pickPermissionOption } = cursorPrivate;
|
|
15
|
+
|
|
16
|
+
test('cursor harness resolves through contract model naming', () => {
|
|
17
|
+
assert.equal(agentToHarness('cursor'), 'cursor');
|
|
18
|
+
assert.equal(canonicalModel('cursor/composer-2.5', 'cursor'), 'cursor/composer-2.5');
|
|
19
|
+
assert.equal(canonicalModel('anthropic/claude-opus-4.8', 'cursor'), 'anthropic/claude-opus-4.8');
|
|
20
|
+
assert.equal(cliModelFor({ harness: 'cursor', modelId: 'cursor/composer-2.5' }), 'composer-2.5');
|
|
21
|
+
assert.equal(cliModelFor({ harness: 'cursor', modelId: 'anthropic/claude-opus-4.8', cliModel: 'claude-opus-4-8' }), 'claude-opus-4-8');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('cursor is provider_auth-only with a stable per-agent CLI home', () => {
|
|
25
|
+
assert.equal(coerceAuth('cursor', 'amalgm'), 'provider_auth');
|
|
26
|
+
assert.equal(coerceAuth('cursor', 'byok'), 'provider_auth');
|
|
27
|
+
const envelope = authEnvelope({
|
|
28
|
+
harness: 'cursor',
|
|
29
|
+
authMethod: 'provider_auth',
|
|
30
|
+
sessionId: 'session-test',
|
|
31
|
+
amalgmDir: '/tmp/amalgm-test',
|
|
32
|
+
agentConfigId: 'qa-agent',
|
|
33
|
+
});
|
|
34
|
+
assert.equal(envelope.method, 'provider_auth');
|
|
35
|
+
assert.equal(envelope.runtimeHome, '/tmp/amalgm-test/cli-homes/cursor/agents/qa-agent/home');
|
|
36
|
+
const env = runtimeEnv({ harness: 'cursor', authMethod: 'provider_auth', auth: envelope }, { PATH: '/bin', HOME: '/Users/example' });
|
|
37
|
+
assert.equal(env.HOME, envelope.runtimeHome);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('matchAcpModel resolves amalgm cliModels to exact ACP model ids', () => {
|
|
41
|
+
const availableModels = [
|
|
42
|
+
{ modelId: 'default[]', name: 'Auto' },
|
|
43
|
+
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
|
|
44
|
+
{ modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'claude-opus-4-8' },
|
|
45
|
+
{ modelId: 'gpt-5.3-codex[reasoning=medium,fast=false]', name: 'gpt-5.3-codex' },
|
|
46
|
+
];
|
|
47
|
+
assert.equal(matchAcpModel(availableModels, 'composer-2.5').modelId, 'composer-2.5[fast=true]');
|
|
48
|
+
assert.equal(matchAcpModel(availableModels, 'cursor/composer-2.5').modelId, 'composer-2.5[fast=true]');
|
|
49
|
+
assert.equal(matchAcpModel(availableModels, 'auto').modelId, 'default[]');
|
|
50
|
+
assert.equal(matchAcpModel(availableModels, 'claude-opus-4-8').modelId, 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]');
|
|
51
|
+
// Legacy effort/speed suffixes strip down to the ACP base model.
|
|
52
|
+
assert.equal(matchAcpModel(availableModels, 'gpt-5.3-codex-high').modelId, 'gpt-5.3-codex[reasoning=medium,fast=false]');
|
|
53
|
+
assert.equal(matchAcpModel(availableModels, 'claude-opus-4-8-thinking-high-fast').modelId, 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]');
|
|
54
|
+
assert.equal(matchAcpModel(availableModels, 'not-a-model'), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('permission requests prefer allow_always then allow_once', () => {
|
|
58
|
+
assert.equal(pickPermissionOption([
|
|
59
|
+
{ optionId: 'r', kind: 'reject_once' },
|
|
60
|
+
{ optionId: 'a1', kind: 'allow_once' },
|
|
61
|
+
{ optionId: 'aa', kind: 'allow_always' },
|
|
62
|
+
]).optionId, 'aa');
|
|
63
|
+
assert.equal(pickPermissionOption([
|
|
64
|
+
{ optionId: 'r', kind: 'reject_once' },
|
|
65
|
+
{ optionId: 'a1', kind: 'allow_once' },
|
|
66
|
+
]).optionId, 'a1');
|
|
67
|
+
assert.equal(pickPermissionOption([]), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('cursor normalizer maps ACP session updates to amalgm events', () => {
|
|
71
|
+
const state = { providerSessionId: 'prov-1', tools: new Map() };
|
|
72
|
+
|
|
73
|
+
const text = normalizeCursorUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, state);
|
|
74
|
+
assert.equal(text[0].type, 'text.delta');
|
|
75
|
+
assert.equal(text[0].text, 'hi');
|
|
76
|
+
assert.equal(text[0].providerSessionId, 'prov-1');
|
|
77
|
+
|
|
78
|
+
const thought = normalizeCursorUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'hm' } }, state);
|
|
79
|
+
assert.equal(thought[0].type, 'reasoning.delta');
|
|
80
|
+
|
|
81
|
+
const started = normalizeCursorUpdate({
|
|
82
|
+
sessionUpdate: 'tool_call',
|
|
83
|
+
toolCallId: 't1',
|
|
84
|
+
title: '`ls`',
|
|
85
|
+
kind: 'execute',
|
|
86
|
+
status: 'pending',
|
|
87
|
+
rawInput: { command: 'ls' },
|
|
88
|
+
}, state);
|
|
89
|
+
assert.equal(started[0].type, 'tool.started');
|
|
90
|
+
assert.equal(started[0].toolCallId, 't1');
|
|
91
|
+
assert.equal(started[0].kind, 'bash');
|
|
92
|
+
assert.deepEqual(started[0].input, { command: 'ls' });
|
|
93
|
+
|
|
94
|
+
const completed = normalizeCursorUpdate({
|
|
95
|
+
sessionUpdate: 'tool_call_update',
|
|
96
|
+
toolCallId: 't1',
|
|
97
|
+
status: 'completed',
|
|
98
|
+
rawOutput: { exitCode: 0, stdout: 'file.txt\n', stderr: '' },
|
|
99
|
+
}, state);
|
|
100
|
+
assert.equal(completed[0].type, 'tool.completed');
|
|
101
|
+
assert.equal(completed[0].isError, false);
|
|
102
|
+
assert.equal(completed[0].toolName, '`ls`');
|
|
103
|
+
assert.deepEqual(completed[0].output, { text: 'file.txt', exitCode: 0 });
|
|
104
|
+
|
|
105
|
+
const editStarted = normalizeCursorUpdate({
|
|
106
|
+
sessionUpdate: 'tool_call', toolCallId: 't2', title: 'Edit File', kind: 'edit', status: 'pending', rawInput: {},
|
|
107
|
+
}, state);
|
|
108
|
+
assert.equal(editStarted[0].kind, 'file_edit');
|
|
109
|
+
const editDone = normalizeCursorUpdate({
|
|
110
|
+
sessionUpdate: 'tool_call_update',
|
|
111
|
+
toolCallId: 't2',
|
|
112
|
+
status: 'completed',
|
|
113
|
+
content: [{ type: 'diff', path: '/tmp/a.txt', oldText: null, newText: 'hello' }],
|
|
114
|
+
}, state);
|
|
115
|
+
assert.equal(editDone[0].type, 'tool.completed');
|
|
116
|
+
assert.deepEqual(editDone[0].output, { path: '/tmp/a.txt', oldText: null, newText: 'hello' });
|
|
117
|
+
|
|
118
|
+
const failed = normalizeCursorUpdate({
|
|
119
|
+
sessionUpdate: 'tool_call', toolCallId: 't3', title: 'Read File', kind: 'read', status: 'pending', rawInput: {},
|
|
120
|
+
}, state) && normalizeCursorUpdate({
|
|
121
|
+
sessionUpdate: 'tool_call_update', toolCallId: 't3', status: 'failed',
|
|
122
|
+
}, state);
|
|
123
|
+
assert.equal(failed[0].type, 'tool.completed');
|
|
124
|
+
assert.equal(failed[0].isError, true);
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(normalizeCursorUpdate({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'replay' } }, state), []);
|
|
127
|
+
assert.deepEqual(normalizeCursorUpdate({ sessionUpdate: 'session_info_update', title: 'x' }, state), []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('cursor usage mappings stay honest about exactness', () => {
|
|
131
|
+
const state = { providerSessionId: 'prov-1', tools: new Map() };
|
|
132
|
+
const snapshot = normalizeCursorUpdate({ sessionUpdate: 'usage_update', used: 1200, size: 200000 }, state);
|
|
133
|
+
assert.equal(snapshot[0].type, 'usage.final');
|
|
134
|
+
assert.equal(snapshot[0].usage.billable, false);
|
|
135
|
+
assert.equal(snapshot[0].usage.exact, false);
|
|
136
|
+
assert.equal(snapshot[0].usage.usedTokens, 1200);
|
|
137
|
+
assert.equal(snapshot[0].usage.maxTokens, 200000);
|
|
138
|
+
|
|
139
|
+
assert.equal(cursorPromptUsage(undefined), null);
|
|
140
|
+
assert.equal(cursorPromptUsage({}), null);
|
|
141
|
+
const exact = cursorPromptUsage({ input_tokens: 10, output_tokens: 20, cached_read_tokens: 5, total_tokens: 35 });
|
|
142
|
+
assert.equal(exact.usage.exact, true);
|
|
143
|
+
assert.equal(exact.usage.billable, true);
|
|
144
|
+
assert.equal(exact.usage.operation, 'chat_turn');
|
|
145
|
+
assert.equal(exact.usage.tokenList.total, 35);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('cursor stop reasons map onto amalgm done reasons', () => {
|
|
149
|
+
assert.equal(cursorStopReason('end_turn'), 'end_turn');
|
|
150
|
+
assert.equal(cursorStopReason('cancelled'), 'cancelled');
|
|
151
|
+
assert.equal(cursorStopReason('refusal'), 'refusal');
|
|
152
|
+
assert.equal(cursorStopReason('max_tokens'), 'max_tokens');
|
|
153
|
+
assert.equal(cursorStopReason(undefined), 'end_turn');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('cursor provider auth sync seeds the managed home without clobbering it', () => {
|
|
157
|
+
const nativeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-native-'));
|
|
158
|
+
const runtimeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-runtime-'));
|
|
159
|
+
const previousNative = process.env.AMALGM_NATIVE_HOME;
|
|
160
|
+
process.env.AMALGM_NATIVE_HOME = nativeHome;
|
|
161
|
+
try {
|
|
162
|
+
fs.mkdirSync(path.join(nativeHome, '.cursor'), { recursive: true });
|
|
163
|
+
fs.writeFileSync(path.join(nativeHome, '.cursor', 'cli-config.json'), '{"version":1}');
|
|
164
|
+
|
|
165
|
+
syncCursorProviderAuth(runtimeHome);
|
|
166
|
+
assert.equal(fs.readFileSync(path.join(runtimeHome, '.cursor', 'cli-config.json'), 'utf8'), '{"version":1}');
|
|
167
|
+
|
|
168
|
+
// The copy is seed-only: a config evolved inside the managed home wins.
|
|
169
|
+
fs.writeFileSync(path.join(runtimeHome, '.cursor', 'cli-config.json'), '{"version":2}');
|
|
170
|
+
syncCursorProviderAuth(runtimeHome);
|
|
171
|
+
assert.equal(fs.readFileSync(path.join(runtimeHome, '.cursor', 'cli-config.json'), 'utf8'), '{"version":2}');
|
|
172
|
+
} finally {
|
|
173
|
+
if (previousNative === undefined) delete process.env.AMALGM_NATIVE_HOME;
|
|
174
|
+
else process.env.AMALGM_NATIVE_HOME = previousNative;
|
|
175
|
+
fs.rmSync(nativeHome, { recursive: true, force: true });
|
|
176
|
+
fs.rmSync(runtimeHome, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const { authEnvelope, coerceAuth, runtimeEnv } = require('../auth');
|
|
9
|
+
const { agentToHarness, canonicalModel, cliModelFor } = require('../contract');
|
|
10
|
+
const { normalizePiEvent, piMessageUsage, piSessionStatsUsage } = require('../normalizers/pi');
|
|
11
|
+
const { finalizeTurnUsage } = require('../usage');
|
|
12
|
+
const { __private: piPrivate } = require('../adapters/pi');
|
|
13
|
+
|
|
14
|
+
test('pi harness resolves gateway model naming', () => {
|
|
15
|
+
assert.equal(agentToHarness('pi'), 'pi');
|
|
16
|
+
assert.equal(canonicalModel('pi-anthropic-claude-sonnet-4.6', 'pi'), 'anthropic/claude-sonnet-4.6');
|
|
17
|
+
assert.equal(canonicalModel('vercel/deepseek/deepseek-v4-pro', 'pi'), 'deepseek/deepseek-v4-pro');
|
|
18
|
+
assert.equal(canonicalModel('anthropic/claude-haiku-4.5', 'pi'), 'anthropic/claude-haiku-4.5');
|
|
19
|
+
assert.equal(canonicalModel('pi-prime-intellect-intellect-3', 'pi'), 'prime-intellect/intellect-3');
|
|
20
|
+
assert.equal(cliModelFor({ harness: 'pi', modelId: 'pi-anthropic-claude-sonnet-4.6' }), 'anthropic/claude-sonnet-4.6');
|
|
21
|
+
assert.equal(cliModelFor({ harness: 'pi', modelId: 'anthropic/claude-haiku-4.5', cliModel: 'vercel/anthropic/claude-haiku-4.5' }), 'anthropic/claude-haiku-4.5');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('pi supports all auth methods with a stable per-agent home and gateway env', () => {
|
|
25
|
+
assert.equal(coerceAuth('pi', 'amalgm'), 'amalgm');
|
|
26
|
+
assert.equal(coerceAuth('pi', 'byok'), 'byok');
|
|
27
|
+
assert.equal(coerceAuth('pi', 'provider_auth'), 'provider_auth');
|
|
28
|
+
const envelope = authEnvelope({
|
|
29
|
+
harness: 'pi',
|
|
30
|
+
authMethod: 'amalgm',
|
|
31
|
+
sessionId: 'session-pi',
|
|
32
|
+
proxyToken: 'proxy-token',
|
|
33
|
+
localBaseUrl: 'http://127.0.0.1:8084',
|
|
34
|
+
proxyBaseUrl: 'https://proxy.example.test',
|
|
35
|
+
amalgmDir: '/tmp/amalgm-test',
|
|
36
|
+
userId: 'user-123',
|
|
37
|
+
});
|
|
38
|
+
assert.equal(envelope.runtimeHome, '/tmp/amalgm-test/cli-homes/pi/agents/pi/home');
|
|
39
|
+
// Pi appends /v1/messages itself, so egress terminates at /ai_gateway.
|
|
40
|
+
assert.equal(envelope.baseUrl, 'http://127.0.0.1:8084/egress/session-pi/ai_gateway');
|
|
41
|
+
assert.equal(envelope.forwardBaseUrl, 'https://proxy.example.test/ai_gateway');
|
|
42
|
+
const env = runtimeEnv({ harness: 'pi', authMethod: 'amalgm', auth: envelope }, { PATH: '/bin' });
|
|
43
|
+
assert.equal(env.AI_GATEWAY_API_KEY, envelope.tokenRef);
|
|
44
|
+
assert.equal(env.AI_GATEWAY_BASE_URL, undefined);
|
|
45
|
+
assert.equal(env.HOME, envelope.runtimeHome);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('pi adapter args carry model, thinking, system prompt, resume, and egress extension', () => {
|
|
49
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-home-'));
|
|
50
|
+
try {
|
|
51
|
+
const contract = {
|
|
52
|
+
cliModel: 'anthropic/claude-haiku-4.5',
|
|
53
|
+
reasoningEffort: 'high',
|
|
54
|
+
providerSessionId: '/sessions/prior.jsonl',
|
|
55
|
+
authMethod: 'amalgm',
|
|
56
|
+
auth: { baseUrl: 'http://127.0.0.1:8084/egress/s/ai_gateway' },
|
|
57
|
+
systemPrompt: 'be helpful',
|
|
58
|
+
agentConfig: null,
|
|
59
|
+
};
|
|
60
|
+
const args = piPrivate.buildArgs(contract, home);
|
|
61
|
+
assert.deepEqual(args.slice(0, 6), ['--mode', 'rpc', '--provider', 'vercel-ai-gateway', '--model', 'anthropic/claude-haiku-4.5']);
|
|
62
|
+
assert.ok(args.includes('--thinking') && args[args.indexOf('--thinking') + 1] === 'high');
|
|
63
|
+
assert.ok(args.includes('--session') && args[args.indexOf('--session') + 1] === '/sessions/prior.jsonl');
|
|
64
|
+
const extension = args[args.indexOf('--extension') + 1];
|
|
65
|
+
assert.ok(extension.startsWith(home));
|
|
66
|
+
const source = fs.readFileSync(extension, 'utf8');
|
|
67
|
+
assert.match(source, /registerProvider\("vercel-ai-gateway"/);
|
|
68
|
+
assert.match(source, /AMALGM_PI_GATEWAY_BASE_URL/);
|
|
69
|
+
// provider_auth spawns without the egress extension.
|
|
70
|
+
const nativeArgs = piPrivate.buildArgs({ ...contract, authMethod: 'provider_auth', providerSessionId: null }, home);
|
|
71
|
+
assert.equal(nativeArgs.includes('--extension'), false);
|
|
72
|
+
assert.equal(nativeArgs.includes('--session'), false);
|
|
73
|
+
} finally {
|
|
74
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('pi normalizer maps rpc events to amalgm events', () => {
|
|
79
|
+
const state = { providerSessionId: 'sess.jsonl', tools: new Set() };
|
|
80
|
+
|
|
81
|
+
const text = normalizePiEvent({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hi' } }, state);
|
|
82
|
+
assert.equal(text[0].type, 'text.delta');
|
|
83
|
+
assert.equal(text[0].text, 'hi');
|
|
84
|
+
|
|
85
|
+
const thinking = normalizePiEvent({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: 'hm' } }, state);
|
|
86
|
+
assert.equal(thinking[0].type, 'reasoning.delta');
|
|
87
|
+
|
|
88
|
+
const started = normalizePiEvent({ type: 'tool_execution_start', toolCallId: 'c1', toolName: 'bash', args: { command: 'ls' } }, state);
|
|
89
|
+
assert.equal(started[0].type, 'tool.started');
|
|
90
|
+
assert.equal(started[0].kind, 'bash');
|
|
91
|
+
|
|
92
|
+
const updated = normalizePiEvent({
|
|
93
|
+
type: 'tool_execution_update',
|
|
94
|
+
toolCallId: 'c1',
|
|
95
|
+
toolName: 'bash',
|
|
96
|
+
partialResult: { content: [{ type: 'text', text: 'partial' }] },
|
|
97
|
+
}, state);
|
|
98
|
+
assert.equal(updated[0].type, 'tool.updated');
|
|
99
|
+
assert.equal(updated[0].output, 'partial');
|
|
100
|
+
|
|
101
|
+
const completed = normalizePiEvent({
|
|
102
|
+
type: 'tool_execution_end',
|
|
103
|
+
toolCallId: 'c1',
|
|
104
|
+
toolName: 'bash',
|
|
105
|
+
result: { content: [{ type: 'text', text: 'done output' }] },
|
|
106
|
+
isError: false,
|
|
107
|
+
}, state);
|
|
108
|
+
assert.equal(completed[0].type, 'tool.completed');
|
|
109
|
+
assert.equal(completed[0].output, 'done output');
|
|
110
|
+
assert.equal(completed[0].isError, false);
|
|
111
|
+
|
|
112
|
+
const err = normalizePiEvent({
|
|
113
|
+
type: 'message_update',
|
|
114
|
+
message: { errorMessage: 'boom' },
|
|
115
|
+
assistantMessageEvent: { type: 'error', reason: 'error' },
|
|
116
|
+
}, state);
|
|
117
|
+
assert.equal(err[0].type, 'error');
|
|
118
|
+
assert.equal(err[0].message, 'boom');
|
|
119
|
+
assert.deepEqual(normalizePiEvent({
|
|
120
|
+
type: 'message_update',
|
|
121
|
+
assistantMessageEvent: { type: 'error', reason: 'aborted' },
|
|
122
|
+
}, state), []);
|
|
123
|
+
|
|
124
|
+
const compaction = normalizePiEvent({ type: 'compaction_end', reason: 'threshold', result: { tokensBefore: 1000 } }, state);
|
|
125
|
+
assert.equal(compaction[0].type, 'compaction.finished');
|
|
126
|
+
assert.equal(compaction[0].preTokens, 1000);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('pi usage is exact per assistant message with native cost', () => {
|
|
130
|
+
const usage = piMessageUsage({
|
|
131
|
+
role: 'assistant',
|
|
132
|
+
usage: {
|
|
133
|
+
input: 100,
|
|
134
|
+
output: 50,
|
|
135
|
+
cacheRead: 400,
|
|
136
|
+
cacheWrite: 20,
|
|
137
|
+
totalTokens: 570,
|
|
138
|
+
cost: { total: 0.0123 },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
assert.equal(usage.usage.exact, true);
|
|
142
|
+
assert.equal(usage.usage.billable, true);
|
|
143
|
+
assert.equal(usage.usage.operation, 'chat_step');
|
|
144
|
+
assert.equal(usage.usage.costUsd, 0.0123);
|
|
145
|
+
assert.deepEqual(usage.usage.tokenList, { input: 100, output: 50, cacheRead: 400, cacheWrite: 20, thought: 0, total: 570 });
|
|
146
|
+
assert.equal(piMessageUsage({ role: 'assistant' }), null);
|
|
147
|
+
assert.equal(piMessageUsage({ role: 'assistant', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } }), null);
|
|
148
|
+
|
|
149
|
+
const snapshot = piSessionStatsUsage({
|
|
150
|
+
tokens: { total: 105000 },
|
|
151
|
+
cost: 0.45,
|
|
152
|
+
contextUsage: { tokens: 60000, contextWindow: 200000, percent: 30 },
|
|
153
|
+
});
|
|
154
|
+
assert.equal(snapshot.usage.billable, false);
|
|
155
|
+
assert.equal(snapshot.usage.exact, false);
|
|
156
|
+
assert.equal(snapshot.usage.usedTokens, 60000);
|
|
157
|
+
assert.equal(snapshot.usage.maxTokens, 200000);
|
|
158
|
+
assert.equal(piSessionStatsUsage({ tokens: { total: 10 } }), null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('pi turn usage sums exact steps and keeps native gateway cost', () => {
|
|
162
|
+
const step = (input, output, cost) => piMessageUsage({
|
|
163
|
+
role: 'assistant',
|
|
164
|
+
usage: { input, output, cacheRead: 0, cacheWrite: 0, totalTokens: input + output, cost: { total: cost } },
|
|
165
|
+
}).usage;
|
|
166
|
+
const snapshot = piSessionStatsUsage({
|
|
167
|
+
tokens: { total: 400 },
|
|
168
|
+
cost: 0.03,
|
|
169
|
+
contextUsage: { tokens: 300, contextWindow: 200000 },
|
|
170
|
+
}).usage;
|
|
171
|
+
const turn = finalizeTurnUsage({
|
|
172
|
+
contract: { harness: 'pi' },
|
|
173
|
+
usageRecords: [step(100, 50, 0.01), snapshot, step(120, 30, 0.02)],
|
|
174
|
+
});
|
|
175
|
+
assert.equal(turn.operation, 'chat_turn');
|
|
176
|
+
assert.equal(turn.exact, true);
|
|
177
|
+
assert.deepEqual([turn.tokenList.input, turn.tokenList.output], [220, 80]);
|
|
178
|
+
assert.equal(turn.costUsd.toFixed(2), '0.03');
|
|
179
|
+
assert.equal(turn.marketCostUsd.toFixed(2), '0.03');
|
|
180
|
+
// Snapshot-only records (no exact steps) must not produce a billing row.
|
|
181
|
+
assert.equal(finalizeTurnUsage({ contract: { harness: 'pi' }, usageRecords: [snapshot] }), null);
|
|
182
|
+
});
|
|
@@ -332,6 +332,40 @@ function syncClaudeProviderAuth(runtimeHome) {
|
|
|
332
332
|
: null;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Cursor stores its login token in the macOS Keychain (service "cursor-access-token"),
|
|
336
|
+
// not in a file, so warm auth in a managed home rides on the Keychains alias.
|
|
337
|
+
// cli-config.json is copied once so the CLI starts from the user's preferences.
|
|
338
|
+
function syncCursorProviderAuth(runtimeHome) {
|
|
339
|
+
if (!runtimeHome) return null;
|
|
340
|
+
const home = nativeHome();
|
|
341
|
+
const sourceDir = path.join(home, '.cursor');
|
|
342
|
+
fs.mkdirSync(runtimeHome, { recursive: true });
|
|
343
|
+
const keychain = ensureNativeKeychainAlias(runtimeHome);
|
|
344
|
+
const copiedAuth = copyFileIfPresent(
|
|
345
|
+
path.join(sourceDir, 'auth.json'),
|
|
346
|
+
path.join(runtimeHome, '.cursor', 'auth.json'),
|
|
347
|
+
);
|
|
348
|
+
const targetConfig = path.join(runtimeHome, '.cursor', 'cli-config.json');
|
|
349
|
+
const copiedConfig = !exists(targetConfig) && copyFileIfPresent(path.join(sourceDir, 'cli-config.json'), targetConfig);
|
|
350
|
+
return keychain || copiedAuth || copiedConfig
|
|
351
|
+
? { sourceDir, runtimeHome, copied: copiedAuth || copiedConfig, keychain, authOnly: true }
|
|
352
|
+
: null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Pi keeps provider keys and custom-provider config under ~/.pi; auth.json is
|
|
356
|
+
// the auth material, models.json/settings.json carry provider definitions the
|
|
357
|
+
// managed home needs to resolve the same models.
|
|
358
|
+
function syncPiProviderAuth(runtimeHome) {
|
|
359
|
+
if (!runtimeHome) return null;
|
|
360
|
+
const home = nativeHome();
|
|
361
|
+
const sourceDir = path.join(home, '.pi');
|
|
362
|
+
fs.mkdirSync(runtimeHome, { recursive: true });
|
|
363
|
+
const copied = ['agent/auth.json', 'agent/models.json', 'agent/settings.json']
|
|
364
|
+
.map((file) => copyFileIfPresent(path.join(sourceDir, file), path.join(runtimeHome, '.pi', file)))
|
|
365
|
+
.some(Boolean);
|
|
366
|
+
return copied ? { sourceDir, runtimeHome, copied: true, authOnly: true } : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
335
369
|
function syncOpenCodeProviderAuth(runtimeHome) {
|
|
336
370
|
if (!runtimeHome) return null;
|
|
337
371
|
const home = nativeHome();
|
|
@@ -400,6 +434,8 @@ function syncNativeProviderAuth(contract) {
|
|
|
400
434
|
if (contract.harness === 'codex') return syncCodexProviderAuth(runtimeHome);
|
|
401
435
|
if (contract.harness === 'claude_code') return syncClaudeProviderAuth(runtimeHome);
|
|
402
436
|
if (contract.harness === 'opencode') return syncOpenCodeProviderAuth(runtimeHome);
|
|
437
|
+
if (contract.harness === 'cursor') return syncCursorProviderAuth(runtimeHome);
|
|
438
|
+
if (contract.harness === 'pi') return syncPiProviderAuth(runtimeHome);
|
|
403
439
|
return null;
|
|
404
440
|
}
|
|
405
441
|
|
|
@@ -421,8 +457,10 @@ module.exports = {
|
|
|
421
457
|
syncClaudeNativeConfig,
|
|
422
458
|
syncCodexProviderAuth,
|
|
423
459
|
syncCodexNativeConfig,
|
|
460
|
+
syncCursorProviderAuth,
|
|
424
461
|
syncNativeHarnessConfig,
|
|
425
462
|
syncNativeProviderAuth,
|
|
426
463
|
syncOpenCodeProviderAuth,
|
|
427
464
|
syncOpenCodeNativeConfig,
|
|
465
|
+
syncPiProviderAuth,
|
|
428
466
|
};
|
|
@@ -267,6 +267,26 @@ function finalizeTurnUsage({ contract, usage, usageRecords } = {}) {
|
|
|
267
267
|
? [usage]
|
|
268
268
|
: [];
|
|
269
269
|
|
|
270
|
+
if (contract?.harness === 'pi') {
|
|
271
|
+
// Pi reports exact per-message usage including native USD cost; a turn is
|
|
272
|
+
// the sum of its assistant messages, and the summed provider cost survives
|
|
273
|
+
// into the turn record instead of being re-derived from the catalog.
|
|
274
|
+
const exactRecords = records.filter((entry) => entry?.exact === true && entry.billable !== false);
|
|
275
|
+
const summed = summedTurnUsage(exactRecords, {
|
|
276
|
+
contextLimit: usage?.contextLimit,
|
|
277
|
+
inputContextSize: usage?.inputContextSize,
|
|
278
|
+
source: 'pi_rpc_step_sum',
|
|
279
|
+
});
|
|
280
|
+
if (summed) {
|
|
281
|
+
const costUsd = exactRecords.reduce((total, entry) => {
|
|
282
|
+
const cost = Number(entry.costUsd);
|
|
283
|
+
return Number.isFinite(cost) ? (total ?? 0) + cost : total;
|
|
284
|
+
}, null);
|
|
285
|
+
return { ...summed, costUsd, marketCostUsd: costUsd ?? summed.marketCostUsd };
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
if (contract?.harness === 'codex') {
|
|
271
291
|
const codexUsage = codexTurnUsage(records, {
|
|
272
292
|
contextLimit: usage?.contextLimit,
|