@astrale-os/cli 0.7.0-alpha.0 → 0.8.1-alpha.0

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 (111) hide show
  1. package/README.md +7 -3
  2. package/dist/astrale.js +128 -98
  3. package/package.json +11 -5
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/elk-api-DVw5WDTH.js +1 -0
  11. package/studio/client/dist/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  12. package/studio/client/dist/assets/index-BQnJ5sgd.css +1 -0
  13. package/studio/client/dist/assets/index-CTbkDp3z.js +81 -0
  14. package/studio/client/dist/assets/index-ChOr3yP0.css +1 -0
  15. package/studio/client/dist/assets/index-Dl709Ra1.js +8 -0
  16. package/studio/client/dist/index.html +2 -2
  17. package/studio/server/agent/ask.test.ts +74 -0
  18. package/studio/server/agent/ask.ts +20 -22
  19. package/studio/server/agent/bridge/client.test.ts +49 -0
  20. package/studio/server/agent/bridge/client.ts +37 -0
  21. package/studio/server/agent/bridge/grant.test.ts +62 -0
  22. package/studio/server/agent/bridge/grant.ts +84 -0
  23. package/studio/server/agent/bridge/routes.test.ts +151 -0
  24. package/studio/server/agent/bridge/routes.ts +148 -0
  25. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  26. package/studio/server/agent/conversation.test.ts +84 -0
  27. package/studio/server/agent/conversation.ts +110 -0
  28. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  29. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  30. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  31. package/studio/server/agent/harness/claude/ask.ts +114 -0
  32. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  33. package/studio/server/agent/harness/claude/command.ts +63 -0
  34. package/studio/server/agent/harness/claude/events.ts +215 -0
  35. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  36. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  37. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  38. package/studio/server/agent/harness/claude/skills.ts +43 -0
  39. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  40. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  41. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  42. package/studio/server/agent/harness/codex/ask.ts +181 -0
  43. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  44. package/studio/server/agent/harness/codex/command.ts +43 -0
  45. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  46. package/studio/server/agent/harness/codex/events.ts +132 -0
  47. package/studio/server/agent/harness/codex/exec.ts +113 -0
  48. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  49. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  50. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  51. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  52. package/studio/server/agent/harness/codex/models.ts +211 -0
  53. package/studio/server/agent/harness/codex/skills.ts +33 -0
  54. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  55. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  56. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  57. package/studio/server/agent/harness/gateway/token.ts +166 -0
  58. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  59. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  60. package/studio/server/agent/harness/process.test.ts +46 -0
  61. package/studio/server/agent/harness/process.ts +104 -0
  62. package/studio/server/agent/harness/registry.ts +37 -0
  63. package/studio/server/agent/harness/selection.test.ts +75 -0
  64. package/studio/server/agent/harness/selection.ts +77 -0
  65. package/studio/server/agent/harness/skills.test.ts +95 -0
  66. package/studio/server/agent/harness/skills.ts +100 -0
  67. package/studio/server/agent/layout.test.ts +95 -0
  68. package/studio/server/agent/notify.ts +12 -0
  69. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  70. package/studio/server/agent/prompts/ask.ts +34 -0
  71. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  72. package/studio/server/agent/prompts/turn.ts +57 -0
  73. package/studio/server/agent/routes.test.ts +176 -0
  74. package/studio/server/agent/routes.ts +214 -0
  75. package/studio/server/agent/run/completion.ts +193 -0
  76. package/studio/server/agent/run/coordinator.test.ts +337 -0
  77. package/studio/server/agent/run/coordinator.ts +104 -0
  78. package/studio/server/agent/run/live-state.ts +73 -0
  79. package/studio/server/agent/run/preparation.ts +162 -0
  80. package/studio/server/agent/run/transcript.test.ts +47 -0
  81. package/studio/server/agent/run/transcript.ts +30 -0
  82. package/studio/server/agent/run/usage.test.ts +46 -0
  83. package/studio/server/{state → agent/run}/usage.ts +4 -4
  84. package/studio/server/agent/stream.ts +37 -0
  85. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  86. package/studio/server/api-agent-loadout.test.ts +69 -0
  87. package/studio/server/api.ts +3 -155
  88. package/studio/server/index.ts +6 -5
  89. package/studio/server/state/comments.test.ts +29 -0
  90. package/studio/server/state/comments.ts +3 -3
  91. package/studio/server/state/settings.test.ts +78 -0
  92. package/studio/server/state/settings.ts +28 -1
  93. package/studio/shared/agent-effort.test.ts +12 -0
  94. package/studio/shared/agent-effort.ts +15 -0
  95. package/studio/shared/agent-models.test.ts +9 -0
  96. package/studio/shared/agent-models.ts +14 -0
  97. package/studio/shared/settings-values.test.ts +18 -0
  98. package/studio/shared/settings-values.ts +20 -0
  99. package/studio/shared/types.ts +58 -7
  100. package/studio/tsconfig.json +23 -0
  101. package/viewer/tsconfig.json +13 -0
  102. package/src/connect-core.test.ts +0 -42
  103. package/src/connect-core.ts +0 -53
  104. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  105. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  106. package/studio/server/agent/bridge.ts +0 -188
  107. package/studio/server/agent/claude.ts +0 -678
  108. package/studio/server/agent/mock.ts +0 -186
  109. package/studio/server/agent/registry.ts +0 -29
  110. package/studio/server/agent/runner.ts +0 -487
  111. package/studio/server/state/harness-token.ts +0 -0
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Domain Studio</title>
7
- <script type="module" crossorigin src="/assets/index-DAC1a9vW.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-huaFafBC.css">
7
+ <script type="module" crossorigin src="/assets/index-CTbkDp3z.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BQnJ5sgd.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -0,0 +1,74 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { registerDomain, unregisterDomain } from '../domain'
7
+ import { updateSettings } from '../state/settings'
8
+ import { runAsk } from './ask'
9
+ import { saveConversation } from './conversation'
10
+
11
+ const roots: string[] = []
12
+ const domainIds: string[] = []
13
+ const envBefore = {
14
+ harness: process.env.DOMAIN_STUDIO_HARNESS,
15
+ model: process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL,
16
+ session: process.env.DOMAIN_STUDIO_MOCK_EXPECT_SESSION,
17
+ }
18
+
19
+ function restore(name: keyof typeof envBefore, variable: string): void {
20
+ const value = envBefore[name]
21
+ if (value === undefined) delete process.env[variable]
22
+ else process.env[variable] = value
23
+ }
24
+
25
+ afterEach(() => {
26
+ restore('harness', 'DOMAIN_STUDIO_HARNESS')
27
+ restore('model', 'DOMAIN_STUDIO_MOCK_EXPECT_MODEL')
28
+ restore('session', 'DOMAIN_STUDIO_MOCK_EXPECT_SESSION')
29
+ while (domainIds.length) unregisterDomain(domainIds.pop()!)
30
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
31
+ })
32
+
33
+ test('Ask uses the active harness model and matching per-harness conversation', async () => {
34
+ const root = mkdtempSync(join(tmpdir(), 'studio-ask-model-'))
35
+ roots.push(root)
36
+ mkdirSync(join(root, 'schema'))
37
+ writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
38
+ writeFileSync(join(root, 'domain.ts'), 'export default {}\n')
39
+ writeFileSync(join(root, 'schema/index.ts'), 'export const Test = {}\n')
40
+ const handle = registerDomain(root)!
41
+ domainIds.push(handle.id)
42
+
43
+ process.env.DOMAIN_STUDIO_HARNESS = 'mock'
44
+ process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL = 'mock-selected-model'
45
+ process.env.DOMAIN_STUDIO_MOCK_EXPECT_SESSION = 'mock-parent-session'
46
+ updateSettings(root, {
47
+ agentModels: {
48
+ mock: 'mock-selected-model',
49
+ claude: 'claude-other-model',
50
+ },
51
+ })
52
+ saveConversation(root, 'mock', {
53
+ sessionId: 'mock-parent-session',
54
+ turns: 2,
55
+ updatedAt: 'now',
56
+ })
57
+ saveConversation(root, 'claude', {
58
+ sessionId: 'claude-other-session',
59
+ turns: 4,
60
+ updatedAt: 'now',
61
+ })
62
+
63
+ const deltas: string[] = []
64
+ const result = await runAsk(
65
+ handle,
66
+ { question: 'Which context is active?' },
67
+ new AbortController().signal,
68
+ (delta) => deltas.push(delta),
69
+ )
70
+
71
+ expect(result.isError).toBe(false)
72
+ expect(result.text).toStartWith('(forked from mock-par…)')
73
+ expect(deltas.join('')).toBe(result.text)
74
+ })
@@ -10,15 +10,13 @@
10
10
  */
11
11
  import type { AnchorRef } from '../../shared/types'
12
12
  import type { DomainHandle } from '../domain'
13
- import type { AskResult } from './types'
13
+ import type { AskResult } from './harness/adapter'
14
14
 
15
15
  import { getBundle } from '../cache'
16
- import { resolveHarnessEnv } from '../state/harness-gateway'
17
- import { readSettings } from '../state/settings'
18
- import { buildAskPrompt, buildAskSystemPrompt } from './prompt'
19
- import { getHarness } from './registry'
20
- import { forkableSession } from './runner'
21
- import { studioSessionId } from './session-id'
16
+ import { readConversation } from './conversation'
17
+ import { resolveHarnessConfiguration } from './harness/selection'
18
+ import { buildAskPrompt, buildAskSystemPrompt } from './prompts/ask'
19
+ import { studioSessionId } from './telemetry'
22
20
 
23
21
  export interface AskRequest {
24
22
  anchor?: AnchorRef
@@ -32,15 +30,22 @@ export async function runAsk(
32
30
  signal: AbortSignal,
33
31
  onDelta: (text: string) => void,
34
32
  ): Promise<AskResult> {
35
- const harness = getHarness()
33
+ const question = String(body?.question ?? '').trim()
34
+ if (!question) return { text: '', isError: true, errorMessage: 'question is required' }
35
+ const resolved = await resolveHarnessConfiguration(handle.root)
36
+ if (!resolved.ok)
37
+ return {
38
+ text: '',
39
+ isError: true,
40
+ errorMessage: `model gateway auth failed — ${resolved.error}`,
41
+ }
42
+ const { harness, model, effort, env } = resolved.configuration
36
43
  if (!harness.ask)
37
44
  return {
38
45
  text: '',
39
46
  isError: true,
40
47
  errorMessage: `${harness.label} does not support side questions`,
41
48
  }
42
- const question = String(body?.question ?? '').trim()
43
- if (!question) return { text: '', isError: true, errorMessage: 'question is required' }
44
49
 
45
50
  const ref = body.anchor?.ref ?? '(no target)'
46
51
  const bundle = await getBundle(handle.id)
@@ -51,22 +56,15 @@ export async function runAsk(
51
56
  ir: bundle?.ir ?? null,
52
57
  overlay: bundle?.overlay,
53
58
  })
54
- const settings = readSettings(handle.root)
55
- const envResult = await resolveHarnessEnv(handle.root)
56
- if (!envResult.ok)
57
- return {
58
- text: '',
59
- isError: true,
60
- errorMessage: `model gateway auth failed — ${envResult.error}`,
61
- }
62
-
63
59
  return harness.ask({
64
60
  root: handle.root,
65
61
  prompt,
66
62
  appendSystemPrompt: buildAskSystemPrompt(),
67
- sessionId: forkableSession(handle.id), // fork the live conversation (undefined ⇒ fresh)
68
- effort: settings.agentEffort,
69
- env: { ...envResult.env, ASTRALE_SESSION: studioSessionId(handle.id) },
63
+ sessionId: readConversation(handle.root, harness.id).sessionId,
64
+ model,
65
+ effort,
66
+ access: resolved.configuration.settings.agentAccess,
67
+ env: { ...env, ASTRALE_SESSION: studioSessionId(handle.id) },
70
68
  signal,
71
69
  onDelta,
72
70
  })
@@ -0,0 +1,49 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { forwardBridgeTool } from './client'
4
+
5
+ test('marks HTTP and transport failures as MCP tool errors', async () => {
6
+ const denied = await forwardBridgeTool(
7
+ 'http://studio.test',
8
+ 'token',
9
+ 'reply',
10
+ {},
11
+ async () => new Response('denied', { status: 401 }),
12
+ )
13
+ expect(denied).toEqual({
14
+ content: [{ type: 'text', text: 'bridge error 401: denied' }],
15
+ isError: true,
16
+ })
17
+
18
+ const unavailable = await forwardBridgeTool(
19
+ 'http://studio.test',
20
+ 'token',
21
+ 'reply',
22
+ {},
23
+ async () => {
24
+ throw new Error('offline')
25
+ },
26
+ )
27
+ expect(unavailable).toEqual({
28
+ content: [{ type: 'text', text: 'bridge call failed: offline' }],
29
+ isError: true,
30
+ })
31
+ })
32
+
33
+ test('keeps the scoped bearer authoritative and returns successful content', async () => {
34
+ let sent: any
35
+ const response = await forwardBridgeTool(
36
+ 'http://studio.test',
37
+ 'scoped',
38
+ 'threads',
39
+ { token: 'forged' },
40
+ async (_url, init) => {
41
+ sent = JSON.parse(String(init?.body))
42
+ return new Response('{"threads":[]}')
43
+ },
44
+ )
45
+ expect(sent).toEqual({ token: 'scoped' })
46
+ expect(response).toEqual({
47
+ content: [{ type: 'text', text: '{"threads":[]}' }],
48
+ })
49
+ })
@@ -0,0 +1,37 @@
1
+ export interface BridgeToolResult {
2
+ content: [{ type: 'text'; text: string }]
3
+ isError?: true
4
+ }
5
+
6
+ type FetchBridge = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
7
+
8
+ function result(text: string, isError = false): BridgeToolResult {
9
+ return {
10
+ content: [{ type: 'text', text }],
11
+ ...(isError ? { isError: true as const } : {}),
12
+ }
13
+ }
14
+
15
+ /** Forward one stdio MCP tool call to the authenticated Studio bridge. */
16
+ export async function forwardBridgeTool(
17
+ base: string,
18
+ token: string,
19
+ route: string,
20
+ args: Record<string, unknown>,
21
+ fetchBridge: FetchBridge = fetch,
22
+ ): Promise<BridgeToolResult> {
23
+ if (!base || !token) return result('bridge not configured', true)
24
+ try {
25
+ const response = await fetchBridge(`${base}/${route}`, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify({ ...args, token }),
29
+ })
30
+ const text = await response.text()
31
+ return response.ok
32
+ ? result(text || 'ok')
33
+ : result(`bridge error ${response.status}: ${text}`, true)
34
+ } catch (error: any) {
35
+ return result(`bridge call failed: ${error?.message ?? error}`, true)
36
+ }
37
+ }
@@ -0,0 +1,62 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { DomainHandle } from '../../domain'
7
+
8
+ import { startBridge } from './grant'
9
+
10
+ const roots: string[] = []
11
+
12
+ afterEach(() => {
13
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
14
+ })
15
+
16
+ function domain(root: string, id: string): DomainHandle {
17
+ return {
18
+ id,
19
+ root,
20
+ configFile: join(root, 'astrale.config.ts'),
21
+ domainFile: join(root, 'domain.ts'),
22
+ schemaDirName: 'schema',
23
+ schemaDir: join(root, 'schema'),
24
+ schemaIndex: join(root, 'schema/index.ts'),
25
+ }
26
+ }
27
+
28
+ test('keeps its bearer out of the harness command line', () => {
29
+ const root = mkdtempSync(join(tmpdir(), 'studio-bridge-'))
30
+ roots.push(root)
31
+
32
+ const bridge = startBridge(domain(root, 'test-domain'), () => {})
33
+ const server = bridge.mcpServers[0]!
34
+ const configPath = server.args?.at(-1)
35
+ expect(server.name).toBe('domain-studio')
36
+ expect(server.approvalMode).toBe('approve')
37
+ expect(configPath).toBeTruthy()
38
+
39
+ const config = JSON.parse(readFileSync(configPath!, 'utf8')) as {
40
+ base: string
41
+ token: string
42
+ }
43
+ expect(server.args?.join(' ')).not.toContain(config.token)
44
+ expect(config.base).toContain('/api/domain/test-domain/agent/bridge')
45
+ expect(existsSync(configPath!)).toBe(true)
46
+ expect(statSync(configPath!).mode & 0o777).toBe(0o600)
47
+
48
+ bridge.dispose()
49
+ expect(existsSync(configPath!)).toBe(false)
50
+ })
51
+
52
+ test('does not let an in-process tool replace its scoped bearer', async () => {
53
+ const root = mkdtempSync(join(tmpdir(), 'studio-bridge-invoke-'))
54
+ roots.push(root)
55
+ const bridge = startBridge(domain(root, 'test-domain'), () => {})
56
+ const server = bridge.mcpServers[0]!
57
+
58
+ await expect(server.invoke?.('list_open_threads', { token: 'forged' })).resolves.toEqual({
59
+ threads: [],
60
+ })
61
+ bridge.dispose()
62
+ })
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { chmodSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import type { StudioEvent } from '../../../shared/types'
6
+ import type { DomainHandle } from '../../domain'
7
+ import type { HarnessMcpServer } from '../harness/adapter'
8
+
9
+ import { removeState, statePath, writeJson } from '../../state/store'
10
+ import { openBridgeSession } from './routes'
11
+
12
+ export interface Bridge {
13
+ enabled: boolean
14
+ mcpServers: HarnessMcpServer[]
15
+ onReply(callback: (commentId: string, text: string) => void): void
16
+ onProgress(callback: (text: string) => void): void
17
+ dispose(): void
18
+ }
19
+
20
+ const TOOL_ROUTES: Record<string, string> = {
21
+ list_open_threads: 'threads',
22
+ reply_to_thread: 'reply',
23
+ resolve_thread: 'resolve',
24
+ post_progress: 'progress',
25
+ raise_question: 'raise_question',
26
+ }
27
+
28
+ let studioPort = Number(process.env.PORT) || 4319
29
+
30
+ export function setBridgePort(port: number): void {
31
+ studioPort = port
32
+ }
33
+
34
+ const MCP_SERVER = join(import.meta.dir, 'stdio.ts')
35
+ const BIN = process.env.DOMAIN_STUDIO_BRIDGE_BUN || process.execPath
36
+
37
+ /** Mint the run-scoped MCP grant and its secret-bearing configuration file. */
38
+ export function startBridge(handle: DomainHandle, notify: (event: StudioEvent) => void): Bridge {
39
+ const token = randomUUID()
40
+ const fileId = randomUUID()
41
+ const session = openBridgeSession(handle, token, notify)
42
+ const base = `http://127.0.0.1:${studioPort}/api/domain/${encodeURIComponent(handle.id)}/agent/bridge`
43
+ const bridgeRel = `.cache/agent/bridge-${fileId}.json`
44
+ writeJson(handle.root, bridgeRel, { base, token })
45
+ const bridgeConfigPath = statePath(handle.root, bridgeRel)
46
+ chmodSync(bridgeConfigPath, 0o600)
47
+
48
+ const server: HarnessMcpServer = {
49
+ name: 'domain-studio',
50
+ command: BIN,
51
+ args: [MCP_SERVER, '--config', bridgeConfigPath],
52
+ required: true,
53
+ approvalMode: 'approve',
54
+ enabledTools: Object.keys(TOOL_ROUTES),
55
+ invoke: async (tool, args) => {
56
+ const route = TOOL_ROUTES[tool]
57
+ if (!route) throw new Error(`unknown bridge tool: ${tool}`)
58
+ const response = await session.invoke(route, args)
59
+ const result = await response.json()
60
+ if (!response.ok)
61
+ throw new Error(
62
+ typeof result?.error === 'string'
63
+ ? result.error
64
+ : `bridge call failed: ${response.status}`,
65
+ )
66
+ return result
67
+ },
68
+ }
69
+
70
+ return {
71
+ enabled: true,
72
+ mcpServers: [server],
73
+ onReply: session.onReply,
74
+ onProgress: session.onProgress,
75
+ dispose: () => {
76
+ session.dispose()
77
+ try {
78
+ removeState(handle.root, bridgeRel)
79
+ } catch {
80
+ /* best-effort */
81
+ }
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,151 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { DomainHandle } from '../../domain'
7
+
8
+ import { readComments, upsertComment } from '../../state/comments'
9
+ import { startBridge } from './grant'
10
+ import { handleBridge } from './routes'
11
+
12
+ const roots: string[] = []
13
+
14
+ afterEach(() => {
15
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
16
+ })
17
+
18
+ function domain(root: string, id: string): DomainHandle {
19
+ return {
20
+ id,
21
+ root,
22
+ configFile: join(root, 'astrale.config.ts'),
23
+ domainFile: join(root, 'domain.ts'),
24
+ schemaDirName: 'schema',
25
+ schemaDir: join(root, 'schema'),
26
+ schemaIndex: join(root, 'schema/index.ts'),
27
+ }
28
+ }
29
+
30
+ test('authorizes one domain and applies live thread mutations', async () => {
31
+ const rootA = mkdtempSync(join(tmpdir(), 'studio-bridge-a-'))
32
+ const rootB = mkdtempSync(join(tmpdir(), 'studio-bridge-b-'))
33
+ roots.push(rootA, rootB)
34
+ const domainA = domain(rootA, 'domain-a')
35
+ const domainB = domain(rootB, 'domain-b')
36
+ const awaiting = upsertComment(rootA, {
37
+ anchors: ['Test'],
38
+ anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
39
+ text: 'please answer',
40
+ })
41
+ upsertComment(rootA, {
42
+ anchors: ['Agent question'],
43
+ anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
44
+ text: 'already answered by author',
45
+ firstRole: 'author',
46
+ })
47
+
48
+ const notifications: string[] = []
49
+ const bridge = startBridge(domainA, (event) =>
50
+ notifications.push(`${event.type}:${'domainId' in event ? event.domainId : ''}`),
51
+ )
52
+ const configPath = bridge.mcpServers[0]!.args!.at(-1)!
53
+ const { token } = JSON.parse(readFileSync(configPath, 'utf8')) as { token: string }
54
+ const replies: string[] = []
55
+ const progress: string[] = []
56
+ bridge.onReply((id, text) => replies.push(`${id}:${text}`))
57
+ bridge.onProgress((text) => progress.push(text))
58
+ const request = new Request('http://studio.test')
59
+
60
+ const invalid = await handleBridge(domainA, 'reply', request, {
61
+ token: 'wrong',
62
+ commentId: awaiting.id,
63
+ text: 'forged',
64
+ })
65
+ const crossDomain = await handleBridge(domainB, 'reply', request, {
66
+ token,
67
+ commentId: awaiting.id,
68
+ text: 'forged',
69
+ })
70
+ expect([invalid.status, crossDomain.status]).toEqual([401, 401])
71
+ expect(readComments(rootA).comments[0]!.thread).toHaveLength(1)
72
+
73
+ const listed = await handleBridge(domainA, 'threads', request, { token })
74
+ expect(await listed.json()).toEqual({
75
+ threads: [
76
+ {
77
+ id: awaiting.id,
78
+ kind: 'comment',
79
+ anchor: 'class.Test',
80
+ file: null,
81
+ latest: 'please answer',
82
+ },
83
+ ],
84
+ })
85
+
86
+ const replied = await handleBridge(domainA, 'reply', request, {
87
+ token,
88
+ commentId: awaiting.id,
89
+ text: 'done',
90
+ resolve: true,
91
+ closeNote: 'complete',
92
+ })
93
+ await handleBridge(domainA, 'progress', request, { token, text: 'working' })
94
+ const raised = await handleBridge(domainA, 'raise_question', request, {
95
+ token,
96
+ ref: 'class.Test.property.value',
97
+ text: 'Which value?',
98
+ options: ['A', 'B'],
99
+ })
100
+
101
+ expect(await replied.json()).toEqual({ ok: true, resolved: true })
102
+ expect(await raised.json()).toMatchObject({ ok: true })
103
+ const comments = readComments(rootA).comments
104
+ expect(comments[0]).toMatchObject({
105
+ id: awaiting.id,
106
+ status: 'closed',
107
+ closeNote: 'complete',
108
+ })
109
+ expect(comments[0]!.thread.at(-1)).toMatchObject({ role: 'author', text: 'done' })
110
+ expect(comments.at(-1)).toMatchObject({
111
+ kind: 'question',
112
+ status: 'open',
113
+ thread: [{ role: 'author', type: 'choice', text: 'Which value?', options: ['A', 'B'] }],
114
+ })
115
+ expect(replies).toEqual([`${awaiting.id}:done`])
116
+ expect(progress).toEqual(['working'])
117
+ expect(notifications).toEqual(['comments:domain-a', 'comments:domain-a'])
118
+
119
+ bridge.dispose()
120
+ const expired = await handleBridge(domainA, 'threads', request, { token })
121
+ expect(expired.status).toBe(401)
122
+ })
123
+
124
+ test('returns committed success when UI notification delivery fails', async () => {
125
+ const root = mkdtempSync(join(tmpdir(), 'studio-bridge-notify-'))
126
+ roots.push(root)
127
+ const handle = domain(root, 'domain-notify')
128
+ const comment = upsertComment(root, {
129
+ anchors: ['Test'],
130
+ anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
131
+ text: 'reply once',
132
+ })
133
+ const bridge = startBridge(handle, () => {
134
+ throw new Error('listener failed')
135
+ })
136
+ const configPath = bridge.mcpServers[0]!.args!.at(-1)!
137
+ const { token } = JSON.parse(readFileSync(configPath, 'utf8')) as { token: string }
138
+
139
+ const response = await handleBridge(handle, 'reply', new Request('http://studio.test'), {
140
+ token,
141
+ commentId: comment.id,
142
+ text: 'committed',
143
+ })
144
+
145
+ expect(response.status).toBe(200)
146
+ expect(readComments(root).comments[0]!.thread.map((entry) => entry.text)).toEqual([
147
+ 'reply once',
148
+ 'committed',
149
+ ])
150
+ bridge.dispose()
151
+ })