@inneranimalmedia/agentsam-sdk 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/docs/PLATFORM_RUNTIME_EVENTS.md +48 -0
  2. package/docs/RELEASES.md +7 -7
  3. package/docs/SOURCE_ARCHITECTURE.md +58 -0
  4. package/docs/TEST_TIERS.md +26 -0
  5. package/migrations/runtime/0001_cli_runtime.sql +298 -0
  6. package/package.json +28 -7
  7. package/packages/agentsam-repository/README.md +15 -0
  8. package/packages/agentsam-repository/package.json +25 -0
  9. package/packages/agentsam-repository/src/contracts.js +113 -0
  10. package/packages/agentsam-repository/src/index.js +3 -0
  11. package/{src/lib → packages/agentsam-repository/src}/merkle/cloudflare-persistence.js +14 -24
  12. package/{src/lib → packages/agentsam-repository/src}/merkle/index.js +1 -0
  13. package/{src/lib → packages/agentsam-repository/src}/merkle/persistence.js +6 -4
  14. package/{src/lib → packages/agentsam-repository/src}/merkle/policy.js +1 -0
  15. package/packages/agentsam-repository/test/contracts.test.mjs +40 -0
  16. package/packages/agentsam-repository/test/git-context.test.mjs +24 -0
  17. package/{test/merkle.test.mjs → packages/agentsam-repository/test/merkle-core.test.mjs} +2 -32
  18. package/{test → packages/agentsam-repository/test}/merkle-persistence.test.mjs +11 -6
  19. package/packages/identity/package.json +1 -1
  20. package/protocol/COMPANY_REPOSITORY_GRAPH_V1.md +91 -0
  21. package/protocol/MERKLE_PERSISTENCE_V1.md +2 -0
  22. package/protocol/MERKLE_PERSISTENCE_V2.md +40 -0
  23. package/protocol/repository/repository-contract.schema.json +24 -0
  24. package/protocol/repository/repository-dependency.schema.json +24 -0
  25. package/protocol/repository/repository-identity.schema.json +17 -0
  26. package/protocol/rpc/v1/common.proto +16 -0
  27. package/protocol/rpc/v1/errors.proto +35 -0
  28. package/protocol/rpc/v1/knowledge.proto +77 -0
  29. package/services/knowledge/package-lock.json +333 -0
  30. package/services/knowledge/package.json +5 -1
  31. package/src/agent/responses-runner.js +63 -35
  32. package/src/capabilities/repository-snapshot.js +3 -3
  33. package/src/cli.js +23 -6
  34. package/src/commands/context-economics.js +17 -2
  35. package/src/commands/context.js +1 -1
  36. package/src/commands/db.js +20 -3
  37. package/src/commands/env.js +90 -0
  38. package/src/commands/knowledge.js +12 -4
  39. package/src/commands/merkle-persist.js +30 -11
  40. package/src/commands/merkle.js +1 -1
  41. package/src/commands/models.js +123 -65
  42. package/src/commands/ollama.js +26 -0
  43. package/src/commands/preferences.js +53 -26
  44. package/src/commands/shell.js +236 -48
  45. package/src/errors/contract.js +236 -0
  46. package/src/errors/index.js +14 -0
  47. package/src/index.js +13 -1
  48. package/src/knowledge/service/auth.js +13 -0
  49. package/src/knowledge/service/grpc-client.js +115 -0
  50. package/src/knowledge/service/grpc-codec.js +237 -0
  51. package/src/knowledge/service/grpc-server.js +83 -0
  52. package/src/knowledge/service/job-engine.js +248 -0
  53. package/src/knowledge/service/server.js +87 -135
  54. package/src/knowledge/source.js +1 -1
  55. package/src/lib/cli-preferences.js +31 -4
  56. package/src/lib/deploy-receipt/index.js +2 -2
  57. package/src/lib/knowledge-docker.js +6 -3
  58. package/src/lib/local-sessions.js +23 -2
  59. package/src/lib/local-status.js +1 -1
  60. package/src/lib/project-config.js +1 -1
  61. package/src/lib/provider-credentials.js +105 -5
  62. package/src/lib/slash-commands.js +4 -3
  63. package/src/local/migrations.js +93 -0
  64. package/src/local/runtime-store.js +141 -0
  65. package/src/local/sqlite.js +2 -0
  66. package/src/local-pty/server.js +113 -51
  67. package/src/models/discovery.js +292 -0
  68. package/src/providers/anthropic-messages.js +192 -0
  69. package/src/providers/cloudflare-chat.js +183 -0
  70. package/src/providers/factory.js +69 -0
  71. package/src/providers/gemini-generate-content.js +208 -0
  72. package/src/providers/index.js +5 -0
  73. package/src/providers/ollama-chat.js +148 -0
  74. package/src/providers/openai-responses.js +226 -75
  75. package/src/repository/index.js +14 -2
  76. package/src/rpc/generated/common_grpc_pb.js +1 -0
  77. package/src/rpc/generated/common_pb.js +536 -0
  78. package/src/rpc/generated/errors_grpc_pb.js +1 -0
  79. package/src/rpc/generated/errors_pb.js +482 -0
  80. package/src/rpc/generated/knowledge_grpc_pb.js +135 -0
  81. package/src/rpc/generated/knowledge_pb.js +2168 -0
  82. package/src/rpc/generated/package.json +3 -0
  83. package/src/security/trust-boundary.js +2 -2
  84. package/src/telemetry/events.js +4 -1
  85. package/src/ui/cli/activity.js +76 -0
  86. package/src/ui/cli/compaction.js +15 -0
  87. package/src/ui/cli/footer.js +39 -0
  88. package/src/ui/cli/help.js +192 -0
  89. package/src/ui/cli/plan.js +20 -0
  90. package/src/ui/cli/runtime-events.js +110 -0
  91. package/src/ui/cli/waiting.js +16 -0
  92. package/src/ui/merkle/render.js +1 -1
  93. package/test/cli/preferences-runtime.test.mjs +11 -0
  94. package/test/cli/runtime-ui.test.mjs +74 -0
  95. package/test/error-diagnostics.test.mjs +57 -1
  96. package/test/fixtures/knowledge-rpc-worker.mjs +16 -0
  97. package/test/integration/cli-help.test.mjs +37 -0
  98. package/test/integration/knowledge-rpc.test.mjs +112 -0
  99. package/test/integration/merkle-cli.test.mjs +61 -0
  100. package/test/integration/merkle-persistence-identity.test.mjs +48 -0
  101. package/test/integration/provider-env-cli.test.mjs +49 -0
  102. package/test/integration/provider-factory.test.mjs +197 -0
  103. package/test/integration/repository-company-graph.test.mjs +90 -0
  104. package/test/integration/runtime-migrations.test.mjs +82 -0
  105. package/test/knowledge-service.test.mjs +5 -0
  106. package/test/knowledge.test.mjs +16 -0
  107. package/test/live/terminal-transport.live.test.mjs +24 -0
  108. package/test/local-sessions.test.mjs +7 -1
  109. package/test/models.test.mjs +101 -4
  110. package/test/ollama.test.mjs +21 -0
  111. package/test/portable-context.test.mjs +1 -1
  112. package/test/provider-credentials.test.mjs +45 -1
  113. package/test/release-hygiene.test.mjs +13 -5
  114. package/test/responses-runner.test.mjs +3 -1
  115. package/test/shell.test.mjs +50 -8
  116. package/test/terminal/local-pty.mock.test.mjs +151 -0
  117. /package/{src/lib → packages/agentsam-repository/src}/git-context.js +0 -0
  118. /package/{src/lib → packages/agentsam-repository/src}/merkle/diff.js +0 -0
  119. /package/{src/lib → packages/agentsam-repository/src}/merkle/filemeta.js +0 -0
  120. /package/{src/lib → packages/agentsam-repository/src}/merkle/git-ignore.js +0 -0
  121. /package/{src/lib → packages/agentsam-repository/src}/merkle/hash.js +0 -0
  122. /package/{src/lib → packages/agentsam-repository/src}/merkle/semantic.js +0 -0
  123. /package/{src/lib → packages/agentsam-repository/src}/merkle/snapshot.js +0 -0
  124. /package/{src/lib → packages/agentsam-repository/src}/merkle/tree.js +0 -0
@@ -0,0 +1,82 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { createLocalSqliteDatabase } from '../../src/local/sqlite.js';
7
+ import { applyRuntimeMigrations } from '../../src/local/migrations.js';
8
+
9
+ test('portable runtime migration installs AgentSam CLI state tables idempotently', async (t) => {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-runtime-migration-'));
11
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
12
+ const db = await createLocalSqliteDatabase(path.join(root, 'agentsam.sqlite'));
13
+ try {
14
+ const first = await applyRuntimeMigrations(db);
15
+ assert.equal(first.applied, 1);
16
+
17
+ const tables = await db.prepare(
18
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'agentsam_%' ORDER BY name"
19
+ ).all();
20
+ const names = new Set(tables.results.map((row) => row.name));
21
+ for (const name of [
22
+ 'agentsam_agent_run',
23
+ 'agentsam_approval_queue',
24
+ 'agentsam_compaction_events',
25
+ 'agentsam_context_digest',
26
+ 'agentsam_cron_runs',
27
+ 'agentsam_plans',
28
+ 'agentsam_schema_migrations',
29
+ 'agentsam_todo',
30
+ ]) assert.equal(names.has(name), true, name);
31
+
32
+ const timers = await db.prepare(
33
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='active_timers'"
34
+ ).first();
35
+ assert.equal(timers.name, 'active_timers');
36
+
37
+ const second = await applyRuntimeMigrations(db);
38
+ assert.equal(second.applied, 0);
39
+ assert.equal(second.results[0].status, 'already_applied');
40
+ } finally {
41
+ db.close();
42
+ }
43
+ });
44
+
45
+ test('ephemeral context digests expire and refresh while durable digests stay durable', async (t) => {
46
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-digest-migration-'));
47
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
48
+ const db = await createLocalSqliteDatabase(path.join(root, 'agentsam.sqlite'));
49
+ try {
50
+ await applyRuntimeMigrations(db);
51
+
52
+ await db.prepare(
53
+ "INSERT INTO agentsam_context_digest (id,digest_type,source_hash,digest_hash,digest_text) VALUES (?,?,?,?,?)"
54
+ ).bind('repo_1', 'repo', 's1', 'h1', 'durable').run();
55
+ const durable = await db.prepare(
56
+ 'SELECT expires_at_unix FROM agentsam_context_digest WHERE id=?'
57
+ ).bind('repo_1').first();
58
+ assert.equal(durable.expires_at_unix, null);
59
+
60
+ await db.prepare(
61
+ "INSERT INTO agentsam_context_digest (id,digest_type,source_hash,digest_hash,digest_text) VALUES (?,?,?,?,?)"
62
+ ).bind('session_1', 'session', 's2', 'h2', 'ephemeral').run();
63
+ const initial = await db.prepare(
64
+ 'SELECT expires_at_unix FROM agentsam_context_digest WHERE id=?'
65
+ ).bind('session_1').first();
66
+ assert.equal(Number(initial.expires_at_unix) > 0, true);
67
+
68
+ await db.prepare(
69
+ 'UPDATE agentsam_context_digest SET expires_at_unix = unixepoch() + 60 WHERE id=?'
70
+ ).bind('session_1').run();
71
+ await db.prepare(
72
+ 'UPDATE agentsam_context_digest SET hit_count = hit_count + 1 WHERE id=?'
73
+ ).bind('session_1').run();
74
+ const refreshed = await db.prepare(
75
+ 'SELECT expires_at_unix, hit_count FROM agentsam_context_digest WHERE id=?'
76
+ ).bind('session_1').first();
77
+ assert.equal(refreshed.hit_count, 1);
78
+ assert.equal(Number(refreshed.expires_at_unix) > Math.floor(Date.now() / 1000) + 2_500_000, true);
79
+ } finally {
80
+ db.close();
81
+ }
82
+ });
@@ -88,6 +88,11 @@ test('portable dockerize stages only SDK runtime, preserves identities/tokens, a
88
88
  const context = path.join(result.written.versionDir, 'context');
89
89
  assert.equal(fs.existsSync(path.join(context, 'customer-secret.js')), false);
90
90
  assert.equal(fs.existsSync(path.join(context, 'src/knowledge/service/server.js')), true);
91
+ assert.equal(fs.existsSync(path.join(context, 'src/knowledge/service/grpc-server.js')), true);
92
+ assert.equal(fs.existsSync(path.join(context, 'src/rpc/generated/knowledge_pb.js')), true);
93
+ assert.equal(fs.existsSync(path.join(context, 'src/rpc/generated/knowledge_grpc_pb.js')), true);
94
+ assert.equal(fs.existsSync(path.join(context, 'src/rpc/generated/package.json')), true);
95
+ assert.equal(fs.existsSync(path.join(context, 'src/errors/contract.js')), true);
91
96
  assert.equal(fs.existsSync(path.join(context, 'package-lock.json')), true);
92
97
  const token = fs.readFileSync(result.tokenFile, 'utf8');
93
98
  assert.equal(fs.statSync(result.tokenFile).mode & 0o777, 0o600);
@@ -114,6 +114,22 @@ test('source safety and setup preserve repository files; read-only plan creates
114
114
  assert.ok(!paths.includes('.env.js')); assert.ok(!paths.includes('ignored/hidden.ts'));
115
115
  });
116
116
 
117
+ test('index and search derive safe local defaults when knowledge.json is absent', async t => {
118
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-configless-'));
119
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
120
+ execFileSync('git', ['init', '-q'], { cwd: root });
121
+ execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:ExampleOrg/ConfiglessRepo.git'], { cwd: root });
122
+ write(root, 'src/real.ts', 'export function configlessSymbol() { return 7; }\n');
123
+ const run = args => JSON.parse(execFileSync(process.execPath, [cli, ...args], { cwd: root, encoding: 'utf8' }));
124
+ assert.equal(fs.existsSync(path.join(root, '.agentsam/knowledge.json')), false);
125
+ const plan = run(['index', 'plan']);
126
+ assert.ok(plan.files >= 1); assert.equal(plan.embedding_inputs, 0);
127
+ assert.equal(fs.existsSync(path.join(root, '.agentsam/knowledge.json')), false);
128
+ assert.equal(run(['index', 'run']).published, true);
129
+ assert.equal(fs.existsSync(path.join(root, '.agentsam/knowledge.json')), false);
130
+ assert.equal(run(['search', 'configlessSymbol']).hits[0].path, 'src/real.ts');
131
+ });
132
+
117
133
  test('two independent repositories work through the same CLI and exported package', async t => {
118
134
  for (const name of ['customer-a', 'customer-b']) {
119
135
  const root = fs.mkdtempSync(path.join(os.tmpdir(), name));
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { WebSocket } from 'ws';
4
+
5
+ const liveUrl = String(process.env.AGENTSAM_LIVE_TERMINAL_WS_URL || '').trim();
6
+
7
+ test('live terminal transport accepts a websocket connection', { skip: !liveUrl }, async (t) => {
8
+ const ws = new WebSocket(liveUrl, {
9
+ headers: process.env.AGENTSAM_LIVE_TERMINAL_AUTH
10
+ ? { authorization: process.env.AGENTSAM_LIVE_TERMINAL_AUTH }
11
+ : undefined,
12
+ });
13
+ t.after(() => { try { ws.close(); } catch {} });
14
+
15
+ await Promise.race([
16
+ new Promise((resolve, reject) => {
17
+ ws.once('open', resolve);
18
+ ws.once('error', reject);
19
+ }),
20
+ new Promise((_, reject) => setTimeout(() => reject(new Error('live terminal websocket open timed out')), 8000)),
21
+ ]);
22
+
23
+ assert.equal(ws.readyState, WebSocket.OPEN);
24
+ });
@@ -27,6 +27,10 @@ test('local sessions persist provider-neutral continuation, usage, cost, and las
27
27
  usage_snapshot: { current_context: { input_tokens: 21_244, window_tokens: 1_050_000 } },
28
28
  cumulative_usage: { input_tokens: 21_244, cached_input_tokens: 60_544, output_tokens: 219, reasoning_tokens: 31 },
29
29
  total_cost_usd: 0.123456,
30
+ cost_breakdown_usd: { input: 0.08, cached_input: 0.01, cache_write: 0.003456, output: 0.03 },
31
+ status: 'paused',
32
+ active_elapsed_ms: 90_000,
33
+ active_started_at: null,
30
34
  }, { home });
31
35
 
32
36
  const loaded = loadLocalSession(session.id, { home });
@@ -36,7 +40,9 @@ test('local sessions persist provider-neutral continuation, usage, cost, and las
36
40
 
37
41
  const receipt = renderSessionReceipt(loaded);
38
42
  assert.match(receipt, /Token usage: total=21,463 input=21,244 \(\+ 60,544 cached\) output=219 reasoning=31/);
39
- assert.match(receipt, /Cost: \$0\.1235/);
43
+ assert.match(receipt, /Spent: \$0\.1235/);
44
+ assert.match(receipt, /Elapsed: 1m 30s/);
45
+ assert.match(receipt, /Cost breakdown: input \$0\.0800 · cached \$0\.0100 · cache write \$0\.003456 · output \$0\.0300/);
40
46
  assert.match(receipt, new RegExp(`agentsam resume ${session.id}`));
41
47
  assert.match(receipt, /Run wrangler whoami/);
42
48
  });
@@ -21,7 +21,7 @@ test('model inventory reports configured API providers without exposing credenti
21
21
  home,
22
22
  discoverRemote: false,
23
23
  env: {
24
- OPENAI_API_KEY: 'secret-openai', GEMINI_API_KEY: '', XAI_API_KEY: 'secret-xai', ANTHROPIC_API_KEY: 'secret-anthropic',
24
+ OPENAI_API_KEY: 'secret-openai', GEMINI_API_KEY: '', XAI_API_KEY: 'secret-xai', ANTHROPIC_API_KEY: 'secret-anthropic', CLOUDFLARE_API_TOKEN: 'secret-cf', ACCOUNT_ID: '33333333333333333333333333333333',
25
25
  OLLAMA_BASE_URL: 'http://127.0.0.1:11434', OLLAMA_MODEL: 'qwen:test', OLLAMA_EMBED_MODEL: 'embed:test',
26
26
  },
27
27
  fetchImpl: async () => response({ models: [{ name: 'qwen:test' }, { name: 'embed:test' }] }),
@@ -30,12 +30,26 @@ test('model inventory reports configured API providers without exposing credenti
30
30
  assert.equal(status.providers.find((row) => row.id === 'gemini').configured, false);
31
31
  assert.equal(status.providers.find((row) => row.id === 'grok').configured, true);
32
32
  assert.equal(status.providers.find((row) => row.id === 'anthropic').configured, true);
33
+ assert.equal(status.providers.find((row) => row.id === 'cloudflare').configured, true);
33
34
  assert.equal(status.local.online, true);
34
35
  assert.deepEqual(status.local.models.map((row) => row.name), ['qwen:test', 'embed:test']);
35
36
  const rendered = renderModelsStatus(status);
36
37
  assert.match(rendered, /OpenAI/);
37
38
  assert.match(rendered, /qwen:test/);
38
- assert.doesNotMatch(rendered, /secret-openai|secret-xai|secret-anthropic/);
39
+ assert.doesNotMatch(rendered, /secret-openai|secret-xai|secret-anthropic|secret-cf/);
40
+ });
41
+
42
+ test('static/reference metadata never invents hosted model availability', async () => {
43
+ const status = await collectModelsStatus({
44
+ env: { OPENAI_API_KEY: 'secret-openai', OLLAMA_BASE_URL: 'http://127.0.0.1:11434' },
45
+ fetchImpl: async () => response({ models: [] }),
46
+ providerFetchImpl: async () => response({ data: [{ id: 'some-other-model' }] }),
47
+ });
48
+
49
+ assert.deepEqual(status.availableModels.map((row) => row.provider_model_id), ['some-other-model']);
50
+ const referenceOnly = status.catalogModels.find((row) => row.provider_model_id === 'gpt-6-astra');
51
+ assert.equal(referenceOnly?.availability, 'unverified');
52
+ assert.equal(referenceOnly?.availability_source, 'sdk_reference');
39
53
  });
40
54
 
41
55
  test('exact hosted model availability is verified against the provider inventory', async () => {
@@ -46,6 +60,89 @@ test('exact hosted model availability is verified against the provider inventory
46
60
  });
47
61
  assert.equal(status.discovery.openai.ok, true);
48
62
  assert.equal(status.discovery.openai.returnedModelCount, 2);
49
- assert.deepEqual(status.availableModels.map((row) => row.provider_model_id), ['gpt-6-astra']);
50
- assert.equal(status.catalogModels[0].availability, 'available');
63
+ assert.deepEqual(status.availableModels.map((row) => row.provider_model_id), ['gpt-6-astra', 'some-other-model']);
64
+ assert.equal(status.availableModels[0].availability_source, 'provider_api');
65
+ assert.equal(status.availableModels[0].context_window_source, 'sdk_reference');
66
+ });
67
+
68
+
69
+
70
+
71
+ test('Gemini and xAI discovery keep per-key limits from provider metadata', async () => {
72
+ const seen = [];
73
+ const status = await collectModelsStatus({
74
+ env: {
75
+ GEMINI_API_KEY: 'gem-key',
76
+ XAI_API_KEY: 'xai-key',
77
+ OLLAMA_BASE_URL: 'http://127.0.0.1:11434',
78
+ },
79
+ fetchImpl: async () => response({ models: [] }),
80
+ providerFetchImpl: async (url, options) => {
81
+ seen.push(url);
82
+ if (url.includes('generativelanguage.googleapis.com')) {
83
+ return response({ models: [{
84
+ name: 'models/gemini-test',
85
+ baseModelId: 'gemini-test',
86
+ displayName: 'Gemini Test',
87
+ inputTokenLimit: 123456,
88
+ outputTokenLimit: 8192,
89
+ supportedGenerationMethods: ['generateContent'],
90
+ thinking: true,
91
+ }] });
92
+ }
93
+ if (url.includes('api.x.ai')) {
94
+ return response({ data: [{
95
+ id: 'grok-test',
96
+ context_length: 256000,
97
+ prompt_text_token_price: 1000,
98
+ cached_prompt_text_token_price: 500,
99
+ completion_text_token_price: 4000,
100
+ }] });
101
+ }
102
+ throw new Error('unexpected URL ' + url);
103
+ },
104
+ });
105
+
106
+ const gemini = status.providerModels.gemini[0];
107
+ assert.equal(gemini.provider_model_id, 'gemini-test');
108
+ assert.equal(gemini.context_window, 123456);
109
+ assert.equal(gemini.context_window_source, 'provider_api');
110
+ assert.deepEqual(gemini.reasoning_efforts, ['auto', 'low', 'medium', 'high']);
111
+
112
+ const grok = status.providerModels.grok[0];
113
+ assert.equal(grok.provider_model_id, 'grok-test');
114
+ assert.equal(grok.context_window, 256000);
115
+ assert.equal(grok.pricing.input, 1);
116
+ assert.equal(grok.pricing.output, 4);
117
+ assert.equal(seen.some((url) => url.includes('generativelanguage.googleapis.com')), true);
118
+ assert.equal(seen.some((url) => url.includes('api.x.ai')), true);
119
+ });
120
+
121
+
122
+ test('Cloudflare discovery is scoped to the loaded account and surfaces text-generation models only', async t => {
123
+ const seen = [];
124
+ const home = tempHome(t);
125
+ const status = await collectModelsStatus({
126
+ home,
127
+ env: { CLOUDFLARE_API_TOKEN: 'secret-cf', ACCOUNT_ID: '44444444444444444444444444444444', OLLAMA_BASE_URL: 'http://127.0.0.1:11434' },
128
+ fetchImpl: async () => response({ models: [] }),
129
+ providerFetchImpl: async (url, options) => {
130
+ seen.push({ url, auth: options.headers.authorization });
131
+ return response({ success: true, result: [
132
+ { name: '@cf/qwen/code', task: { name: 'Text Generation' }, description: 'coding' },
133
+ { name: '@cf/baai/embed', task: { name: 'Text Embeddings' }, description: 'embeddings' },
134
+ ] });
135
+ },
136
+ });
137
+ assert.equal(seen.length, 1);
138
+ assert.match(seen[0].url, /accounts\/44444444444444444444444444444444\/ai\/models\/search$/);
139
+ assert.equal(status.discovery.cloudflare.ok, true);
140
+ assert.equal(status.discovery.cloudflare.returnedModelCount, 1);
141
+ assert.deepEqual(status.providerModels.cloudflare.map((row) => row.provider_model_id), ['@cf/qwen/code']);
142
+ assert.equal(status.providerModels.cloudflare[0].availability_source, 'provider_api');
143
+ assert.equal(status.providerModels.cloudflare[0].context_window_source, 'unknown');
144
+ const rendered = renderModelsStatus(status);
145
+ assert.match(rendered, /@cf\/qwen\/code/);
146
+ assert.doesNotMatch(rendered, /@cf\/baai\/embed/);
147
+ assert.doesNotMatch(rendered, /secret-cf/);
51
148
  });
@@ -8,6 +8,7 @@ import {
8
8
  ollamaInstallPlan,
9
9
  parseOllamaArgs,
10
10
  probeOllama,
11
+ probeOllamaModel,
11
12
  resolveOllamaConfig,
12
13
  updateProjectOllamaConfig,
13
14
  upsertOllamaEnvFile,
@@ -82,6 +83,26 @@ test('status probe recognizes configured chat and embedding models', async () =>
82
83
  assert.equal(status.embed_ready, true);
83
84
  });
84
85
 
86
+ test('local model metadata is accepted only after Ollama itself verifies the model', async () => {
87
+ let seenBody = null;
88
+ const fakeFetch = async (url, init) => {
89
+ assert.match(url, /\/api\/show$/);
90
+ seenBody = JSON.parse(init.body);
91
+ return new Response(JSON.stringify({
92
+ model_info: { 'qwen2.context_length': 32768 },
93
+ capabilities: ['completion', 'tools'],
94
+ details: { family: 'qwen2' },
95
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
96
+ };
97
+
98
+ const result = await probeOllamaModel('qwen2.5-coder', OLLAMA_DEFAULTS, fakeFetch);
99
+ assert.equal(seenBody.model, 'qwen2.5-coder');
100
+ assert.equal(result.ok, true);
101
+ assert.equal(result.context_window, 32768);
102
+ assert.equal(result.context_window_source, 'local_runtime');
103
+ assert.deepEqual(result.capabilities, ['completion', 'tools']);
104
+ });
105
+
85
106
  test('automatic install plans use local package managers, never a remote shell script', () => {
86
107
  assert.deepEqual(ollamaInstallPlan('darwin', { brew: true }), {
87
108
  command: 'brew', args: ['install', 'ollama'], manager: 'homebrew',
@@ -8,7 +8,7 @@ import { join } from 'node:path';
8
8
  import {
9
9
  normalizeGitRemote,
10
10
  resolveGitContext,
11
- } from '../src/lib/git-context.js';
11
+ } from '../packages/agentsam-repository/src/git-context.js';
12
12
  import {
13
13
  buildBridgeHeaders,
14
14
  createBridgeClient,
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
- import { describeProviderCredential, resolveProviderCredential } from '../src/lib/provider-credentials.js';
6
+ import { describeProviderCredential, ensureProviderEnvProfile, resolveProviderCredential } from '../src/lib/provider-credentials.js';
7
7
 
8
8
  function fixtureHome(t) {
9
9
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-credentials-'));
@@ -50,3 +50,47 @@ test('AgentSam refuses provider credential files with broad POSIX permissions',
50
50
  assert.equal(resolved.error, 'permissions_too_open');
51
51
  assert.equal(resolved.value, '');
52
52
  });
53
+
54
+
55
+ test('provider env profiles create a secure reusable source loader without embedding secrets', { skip: process.platform === 'win32' }, t => {
56
+ const home = fixtureHome(t);
57
+ const openai = ensureProviderEnvProfile('openai', { home });
58
+ assert.equal(openai.source_command, 'source ~/.agentsam/load-agent-env.sh openai');
59
+ assert.equal(fs.statSync(openai.file).mode & 0o777, 0o600);
60
+ assert.equal(fs.statSync(openai.loader).mode & 0o777, 0o700);
61
+ assert.match(fs.readFileSync(openai.file, 'utf8'), /OPENAI_API_KEY=""/);
62
+ assert.doesNotMatch(fs.readFileSync(openai.loader, 'utf8'), /sk-|secret-/);
63
+
64
+ const cloudflare = ensureProviderEnvProfile('cloudflare', { home });
65
+ const source = fs.readFileSync(cloudflare.file, 'utf8');
66
+ assert.match(source, /ACCOUNT_ID=""/);
67
+ assert.match(source, /CLOUDFLARE_API_TOKEN=""/);
68
+ });
69
+
70
+ test('Cloudflare credential status carries non-secret account identity from the provider profile', t => {
71
+ const home = fixtureHome(t);
72
+ const profile = path.join(home, '.agentsam', 'env.d', 'cloudflare.env');
73
+ fs.writeFileSync(profile, 'export ACCOUNT_ID="0123456789abcdef0123456789abcdef"\nexport CLOUDFLARE_API_TOKEN="token-secret"\n', { mode: 0o600 });
74
+ if (process.platform !== 'win32') fs.chmodSync(profile, 0o600);
75
+ const resolved = resolveProviderCredential('cloudflare', { env: {}, home });
76
+ assert.equal(resolved.account_id, '0123456789abcdef0123456789abcdef');
77
+ const safe = describeProviderCredential('cloudflare', { env: {}, home });
78
+ assert.equal(safe.account_id, '0123456789abcdef0123456789abcdef');
79
+ assert.doesNotMatch(JSON.stringify(safe), /token-secret/);
80
+ });
81
+
82
+
83
+ test('Cloudflare profile account backfill never overwrites an existing token or account choice', t => {
84
+ const home = fixtureHome(t);
85
+ const profile = path.join(home, '.agentsam', 'env.d', 'cloudflare.env');
86
+ fs.writeFileSync(profile, 'export ACCOUNT_ID=""\nexport CLOUDFLARE_API_TOKEN="keep-me"\n', { mode: 0o600 });
87
+ ensureProviderEnvProfile('cloudflare', { home, accountId: '11111111111111111111111111111111' });
88
+ let source = fs.readFileSync(profile, 'utf8');
89
+ assert.match(source, /ACCOUNT_ID="11111111111111111111111111111111"/);
90
+ assert.match(source, /CLOUDFLARE_API_TOKEN="keep-me"/);
91
+
92
+ ensureProviderEnvProfile('cloudflare', { home, accountId: '22222222222222222222222222222222' });
93
+ source = fs.readFileSync(profile, 'utf8');
94
+ assert.match(source, /ACCOUNT_ID="11111111111111111111111111111111"/);
95
+ assert.doesNotMatch(source, /22222222222222222222222222222222/);
96
+ });
@@ -6,15 +6,23 @@ import test from 'node:test';
6
6
  const root = path.resolve(import.meta.dirname, '..');
7
7
  const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
8
8
 
9
- test('2.6 release metadata and public terminal vocabulary are aligned', () => {
9
+ test('release candidate metadata and public terminal vocabulary are aligned', () => {
10
10
  const pkg = JSON.parse(read('package.json'));
11
11
  const manifest = read('agentsam.yaml');
12
12
  const readme = read('README.md');
13
+ const escapedVersion = pkg.version.replaceAll('.', '\\.');
13
14
 
14
- assert.equal(pkg.version, '2.6.0');
15
- assert.match(manifest, /target_version: "2.6.0"/);
16
- assert.match(manifest, /state: release_candidate/);
17
- assert.match(manifest, /current_latest: "2.5.0"/);
15
+ assert.match(
16
+ manifest,
17
+ new RegExp(`packages:[\\s\\S]*?root_sdk:[\\s\\S]*?version: "${escapedVersion}"`),
18
+ );
19
+ assert.match(
20
+ manifest,
21
+ new RegExp(`release:[\\s\\S]*?root_sdk:[\\s\\S]*?target_version: "${escapedVersion}"`),
22
+ );
23
+ assert.match(manifest, /state: (candidate|published)/);
24
+ assert.match(manifest, /current_latest: "\d+\.\d+\.\d+"/);
25
+ assert.match(manifest, /verification_command: npm run verify:release/);
18
26
  assert.doesNotMatch(manifest, /^\s*- tui\s*$/m);
19
27
  assert.doesNotMatch(readme, /agentsam tui|CLI\/TUI/);
20
28
  });
@@ -13,7 +13,7 @@ function usage(input = 10_000, cumulative = input) {
13
13
  estimate_kind: 'provider', provider_authoritative: true,
14
14
  };
15
15
  }
16
- function cost(total = 0.1) { return { total_usd: total }; }
16
+ function cost(total = 0.1) { return { total_usd: total, components_usd: { input: total * 0.5, cached_input: total * 0.1, cache_write: total * 0.1, output: total * 0.3 } }; }
17
17
 
18
18
  test('capability adapter hydrates packaged JSON schemas and tool surface exposes only selected executable schemas', () => {
19
19
  const adapter = createCapabilityAdapter();
@@ -73,6 +73,8 @@ test('runner owns cwd, executes selected tool, preserves call_id and returns pro
73
73
  assert.equal(result.output_text, 'done');
74
74
  assert.equal(result.response_id, 'resp_2');
75
75
  assert.equal(result.total_cost_usd, 0.5);
76
+ assert.equal(result.cost_breakdown_usd.input, 0.25);
77
+ assert.equal(result.cost_breakdown_usd.output, 0.15);
76
78
  assert.equal(result.continuation.compact_before_next_turn, false);
77
79
  assert.ok(events.some(event => event.type === 'tool.search'));
78
80
  assert.ok(events.some(event => event.type === 'tool.completed'));
@@ -22,12 +22,15 @@ test('interactive prompt derives username and cwd instead of hardcoding Agent Sa
22
22
  assert.equal(renderShellPrompt('/tmp/demo', env), 'alice /tmp/demo > ');
23
23
  });
24
24
 
25
- test('shell catalog only advertises implemented core controls', () => {
25
+ test('shell startup stays quiet and points to the picker', () => {
26
26
  const catalog = renderShellCatalog();
27
- for (const command of ['/model', '/reasoning', '/fast', '/flex', '/standard', '/context', '/cf', '/diff', '/clear', '/exit']) {
27
+ for (const command of ['/model', '/usage', '/help', '/exit']) {
28
28
  assert.match(catalog, new RegExp(command.replace('/', '\\/')));
29
29
  }
30
- assert.match(catalog, /scrollable command picker/);
30
+ assert.match(catalog, /command picker/);
31
+ assert.match(catalog, /Type normally to work with the selected model/);
32
+ assert.doesNotMatch(catalog, /Slash commands \(/);
33
+ assert.doesNotMatch(catalog, /\/reasoning\s+Set reasoning/);
31
34
  });
32
35
 
33
36
  test('dispatch handles help, menu fallback, pwd, cd, and exit without falling through to host shell', async () => {
@@ -38,10 +41,11 @@ test('dispatch handles help, menu fallback, pwd, cd, and exit without falling th
38
41
  const state = { cwd: root, write: (text) => { output += text; }, interactive: false };
39
42
  let result = await dispatchShellLine('/help', state);
40
43
  assert.equal(result.handled, true);
41
- assert.match(output, /Slash commands/);
44
+ assert.match(output, /Type normally to work with Agent Sam/);
45
+ assert.match(output, /agentsam help <topic>/);
42
46
  output = '';
43
47
  await dispatchShellLine('/', state);
44
- assert.match(output, /Agent Sam Terminal/);
48
+ assert.match(output, /command picker/);
45
49
  output = '';
46
50
  await dispatchShellLine('/pwd', state);
47
51
  assert.equal(output.trim(), root);
@@ -53,6 +57,44 @@ test('dispatch handles help, menu fallback, pwd, cd, and exit without falling th
53
57
  assert.equal(result.exit, true);
54
58
  });
55
59
 
60
+ test('/usage renders the current session receipt without ending the session', async () => {
61
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-shell-usage-'));
62
+ let output = '';
63
+ const state = {
64
+ cwd: root, interactive: false, write: (text) => { output += text; },
65
+ session: {
66
+ id: 'asess_00000000-0000-4000-8000-000000000001', title: 'Usage test', model_key: 'openai:gpt-6-astra',
67
+ cumulative_usage: { input_tokens: 21_244, cached_input_tokens: 60_544, output_tokens: 219 },
68
+ total_cost_usd: 0.42, cost_breakdown_usd: { input: 0.2, cached_input: 0.02, output: 0.2 },
69
+ },
70
+ };
71
+ const result = await dispatchShellLine('/usage', state);
72
+ assert.equal(result.exit, false);
73
+ assert.match(output, /Token usage: total=21,463 input=21,244 \(\+ 60,544 cached\) output=219/);
74
+ assert.match(output, /Spent: \$0\.4200/);
75
+ assert.match(output, /agentsam resume asess_00000000-0000-4000-8000-000000000001/);
76
+ });
77
+
78
+ test('/logout signs out locally and emits the same resumable usage receipt', async () => {
79
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-shell-logout-'));
80
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-shell-home-'));
81
+ let output = '';
82
+ const state = {
83
+ cwd: root, home, interactive: false, write: (text) => { output += text; },
84
+ session: {
85
+ id: 'asess_00000000-0000-4000-8000-000000000002', title: 'Logout test', model_key: 'openai:gpt-6-astra',
86
+ cumulative_usage: { input_tokens: 100, cached_input_tokens: 50, output_tokens: 25 },
87
+ total_cost_usd: 0.0125, cost_breakdown_usd: { input: 0.005, cached_input: 0.0025, output: 0.005 },
88
+ },
89
+ };
90
+ const result = await dispatchShellLine('/logout', state);
91
+ assert.equal(result.exit, false);
92
+ assert.match(output, /No local Agent Sam IAM session was stored/);
93
+ assert.match(output, /Token usage: total=125 input=100 \(\+ 50 cached\) output=25/);
94
+ assert.match(output, /Spent: \$0\.0125/);
95
+ assert.match(output, /agentsam resume asess_00000000-0000-4000-8000-000000000002/);
96
+ });
97
+
56
98
  test('reasoning and service-tier commands persist only supported controls for an exact model', async () => {
57
99
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-shell-model-'));
58
100
  fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'model-demo' }));
@@ -88,7 +130,7 @@ test('bare /context shows truthful economics without inventing active token usag
88
130
  test('CLI supports a deterministic one-shot slash command for regression tests', () => {
89
131
  const result = spawnSync(process.execPath, ['src/cli.js', 'shell', '--command', '/help'], { cwd: repoRoot, encoding: 'utf8' });
90
132
  assert.equal(result.status, 0, result.stderr);
91
- assert.match(result.stdout, /Agent Sam Terminal/);
92
- assert.match(result.stdout, /\/model/);
93
- assert.match(result.stdout, /\/exit/);
133
+ assert.match(result.stdout, /Type normally to work with Agent Sam/);
134
+ assert.match(result.stdout, /agentsam help <topic>/);
135
+ assert.match(result.stdout, /command picker/);
94
136
  });