@debugg-ai/debugg-ai-mcp 1.0.62 → 1.0.64

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 (48) hide show
  1. package/dist/config/index.js +4 -1
  2. package/dist/handlers/createEnvironmentHandler.js +33 -0
  3. package/dist/handlers/createProjectHandler.js +62 -10
  4. package/dist/handlers/index.js +5 -14
  5. package/dist/handlers/searchEnvironmentsHandler.js +122 -0
  6. package/dist/handlers/searchExecutionsHandler.js +71 -0
  7. package/dist/handlers/searchProjectsHandler.js +72 -0
  8. package/dist/handlers/testPageChangesHandler.js +46 -5
  9. package/dist/handlers/triggerCrawlHandler.js +208 -0
  10. package/dist/handlers/updateEnvironmentHandler.js +94 -15
  11. package/dist/index.js +15 -2
  12. package/dist/services/workflows.js +10 -6
  13. package/dist/tools/createEnvironment.js +5 -1
  14. package/dist/tools/index.js +12 -42
  15. package/dist/tools/searchEnvironments.js +35 -0
  16. package/dist/tools/searchExecutions.js +31 -0
  17. package/dist/tools/searchProjects.js +30 -0
  18. package/dist/tools/triggerCrawl.js +99 -0
  19. package/dist/types/index.js +64 -71
  20. package/package.json +4 -1
  21. package/dist/handlers/cancelExecutionHandler.js +0 -41
  22. package/dist/handlers/createCredentialHandler.js +0 -60
  23. package/dist/handlers/deleteCredentialHandler.js +0 -51
  24. package/dist/handlers/getCredentialHandler.js +0 -49
  25. package/dist/handlers/getEnvironmentHandler.js +0 -49
  26. package/dist/handlers/getExecutionHandler.js +0 -37
  27. package/dist/handlers/getProjectHandler.js +0 -37
  28. package/dist/handlers/listCredentialsHandler.js +0 -93
  29. package/dist/handlers/listEnvironmentsHandler.js +0 -63
  30. package/dist/handlers/listExecutionsHandler.js +0 -35
  31. package/dist/handlers/listProjectsHandler.js +0 -32
  32. package/dist/handlers/listReposHandler.js +0 -27
  33. package/dist/handlers/listTeamsHandler.js +0 -27
  34. package/dist/handlers/updateCredentialHandler.js +0 -70
  35. package/dist/tools/cancelExecution.js +0 -22
  36. package/dist/tools/createCredential.js +0 -52
  37. package/dist/tools/deleteCredential.js +0 -24
  38. package/dist/tools/getCredential.js +0 -24
  39. package/dist/tools/getEnvironment.js +0 -23
  40. package/dist/tools/getExecution.js +0 -22
  41. package/dist/tools/getProject.js +0 -22
  42. package/dist/tools/listCredentials.js +0 -30
  43. package/dist/tools/listEnvironments.js +0 -28
  44. package/dist/tools/listExecutions.js +0 -24
  45. package/dist/tools/listProjects.js +0 -27
  46. package/dist/tools/listRepos.js +0 -23
  47. package/dist/tools/listTeams.js +0 -23
  48. package/dist/tools/updateCredential.js +0 -28
@@ -28,7 +28,10 @@ const configSchema = z.object({
28
28
  version: z.string(),
29
29
  }),
30
30
  api: z.object({
31
- key: z.string().min(1, 'API key is required (set DEBUGGAI_API_KEY)'),
31
+ // key is validated at tool-call time (not at boot) so MCP clients can surface
32
+ // a proper error message instead of seeing the subprocess die → "Failed to
33
+ // reconnect". See bead cma + flow 25.
34
+ key: z.string(),
32
35
  tokenType: z.enum(['token', 'bearer']).default('token'),
33
36
  baseUrl: z.string().url().default('https://api.debugg.ai'),
34
37
  }),
@@ -44,6 +44,39 @@ export async function createEnvironmentHandler(input, _context) {
44
44
  projectUuid,
45
45
  environment: env,
46
46
  };
47
+ // Optional credentials seed: best-effort per-cred. Success goes to
48
+ // credentials[]; failure goes to credentialWarnings[] (never blocks env creation).
49
+ if (input.credentials && input.credentials.length > 0) {
50
+ const created = [];
51
+ const warnings = [];
52
+ for (const seed of input.credentials) {
53
+ try {
54
+ const cred = await client.createCredential(projectUuid, env.uuid, {
55
+ label: seed.label,
56
+ username: seed.username,
57
+ password: seed.password,
58
+ role: seed.role,
59
+ });
60
+ // Defensive: drop any stray password from the response shape
61
+ created.push({
62
+ uuid: cred.uuid,
63
+ label: cred.label,
64
+ username: cred.username,
65
+ role: cred.role ?? null,
66
+ environmentUuid: cred.environmentUuid,
67
+ });
68
+ }
69
+ catch (err) {
70
+ warnings.push({
71
+ label: seed.label,
72
+ error: err?.message ?? String(err),
73
+ });
74
+ }
75
+ }
76
+ payload.credentials = created;
77
+ if (warnings.length > 0)
78
+ payload.credentialWarnings = warnings;
79
+ }
47
80
  logger.toolComplete('create_environment', Date.now() - start);
48
81
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
49
82
  }
@@ -3,26 +3,78 @@ import { handleExternalServiceError } from '../utils/errors.js';
3
3
  import { DebuggAIServerClient } from '../services/index.js';
4
4
  import { config } from '../config/index.js';
5
5
  const logger = new Logger({ module: 'createProjectHandler' });
6
+ function errorResp(error, message, extra = {}) {
7
+ return {
8
+ content: [{
9
+ type: 'text',
10
+ text: JSON.stringify({ error, message, ...extra }, null, 2),
11
+ }],
12
+ isError: true,
13
+ };
14
+ }
15
+ /**
16
+ * Resolve a name to a single uuid via backend search + exact (case-insensitive)
17
+ * match. Returns either a uuid, a NotFound error, or an Ambiguous error with
18
+ * candidate options surfaced.
19
+ */
20
+ function resolveName(name, candidates, kind) {
21
+ const needle = name.toLowerCase();
22
+ const matches = candidates.filter(c => c.name.toLowerCase() === needle);
23
+ if (matches.length === 0) {
24
+ return {
25
+ error: `${kind}NotFound`,
26
+ message: `No ${kind.toLowerCase()} matching "${name}" found. ` +
27
+ (candidates.length > 0
28
+ ? `Available: ${candidates.slice(0, 10).map(c => `"${c.name}"`).join(', ')}`
29
+ : '(none accessible to this API key)'),
30
+ };
31
+ }
32
+ if (matches.length > 1) {
33
+ return {
34
+ error: 'AmbiguousMatch',
35
+ message: `Multiple ${kind.toLowerCase()}s match "${name}". Pass ${kind.toLowerCase()}Uuid explicitly.`,
36
+ candidates: matches.map(m => ({ uuid: m.uuid, name: m.name })),
37
+ };
38
+ }
39
+ return { uuid: matches[0].uuid };
40
+ }
6
41
  export async function createProjectHandler(input, _context) {
7
42
  const start = Date.now();
8
43
  logger.toolStart('create_project', {
9
- name: input.name,
10
- platform: input.platform,
11
- teamUuid: input.teamUuid,
12
- repoUuid: input.repoUuid,
44
+ name: input.name, platform: input.platform,
45
+ teamUuid: input.teamUuid, teamName: input.teamName,
46
+ repoUuid: input.repoUuid, repoName: input.repoName,
13
47
  });
14
48
  try {
15
49
  const client = new DebuggAIServerClient(config.api.key);
16
50
  await client.init();
51
+ // Resolve team
52
+ let teamUuid = input.teamUuid;
53
+ if (!teamUuid && input.teamName) {
54
+ const teamsResp = await client.listTeams({ page: 1, pageSize: 100 }, input.teamName);
55
+ const resolved = resolveName(input.teamName, teamsResp.teams, 'Team');
56
+ if ('error' in resolved)
57
+ return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
58
+ teamUuid = resolved.uuid;
59
+ }
60
+ // Resolve repo
61
+ let repoUuid = input.repoUuid;
62
+ if (!repoUuid && input.repoName) {
63
+ const reposResp = await client.listRepos({ page: 1, pageSize: 100 }, input.repoName);
64
+ const resolved = resolveName(input.repoName, reposResp.repos, 'Repo');
65
+ if ('error' in resolved)
66
+ return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
67
+ repoUuid = resolved.uuid;
68
+ }
69
+ if (!teamUuid || !repoUuid) {
70
+ // Schema-level invariant should have caught this, but defensive.
71
+ return errorResp('ValidationError', `Unable to resolve ${!teamUuid ? 'team' : 'repo'} — provide teamUuid/teamName and repoUuid/repoName.`);
72
+ }
17
73
  const project = await client.createProject({
18
- name: input.name,
19
- platform: input.platform,
20
- teamUuid: input.teamUuid,
21
- repoUuid: input.repoUuid,
74
+ name: input.name, platform: input.platform, teamUuid, repoUuid,
22
75
  });
23
- const payload = { created: true, project };
24
76
  logger.toolComplete('create_project', Date.now() - start);
25
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
77
+ return { content: [{ type: 'text', text: JSON.stringify({ created: true, project }, null, 2) }] };
26
78
  }
27
79
  catch (error) {
28
80
  logger.toolError('create_project', error, Date.now() - start);
@@ -1,21 +1,12 @@
1
1
  export * from './testPageChangesHandler.js';
2
- export * from './listEnvironmentsHandler.js';
3
- export * from './listCredentialsHandler.js';
4
- export * from './listProjectsHandler.js';
2
+ export * from './triggerCrawlHandler.js';
3
+ export * from './searchProjectsHandler.js';
4
+ export * from './searchEnvironmentsHandler.js';
5
+ export * from './searchExecutionsHandler.js';
5
6
  export * from './createEnvironmentHandler.js';
6
- export * from './createCredentialHandler.js';
7
- export * from './getEnvironmentHandler.js';
8
7
  export * from './updateEnvironmentHandler.js';
9
8
  export * from './deleteEnvironmentHandler.js';
10
- export * from './getCredentialHandler.js';
11
- export * from './updateCredentialHandler.js';
12
- export * from './deleteCredentialHandler.js';
13
- export * from './getProjectHandler.js';
9
+ // Credential mutations are folded into create_environment + update_environment.
14
10
  export * from './updateProjectHandler.js';
15
11
  export * from './deleteProjectHandler.js';
16
- export * from './listExecutionsHandler.js';
17
- export * from './getExecutionHandler.js';
18
- export * from './cancelExecutionHandler.js';
19
- export * from './listTeamsHandler.js';
20
- export * from './listReposHandler.js';
21
12
  export * from './createProjectHandler.js';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * search_environments handler (bead 5kw)
3
+ *
4
+ * Absorbs list_environments + get_environment + all credential search.
5
+ * Each environment in the response has its credentials expanded inline.
6
+ *
7
+ * Modes:
8
+ * uuid mode: {uuid, projectUuid?} → {filter:{uuid}, project, pageInfo:{totalCount:1,...},
9
+ * environments:[{...env, credentials:[...]}]}
10
+ * filter mode: {projectUuid?, q?, page?, pageSize?} → paginated list, creds inline per env
11
+ *
12
+ * Invariants:
13
+ * - NEVER returns a password field anywhere in the response (defensive strip at handler edge)
14
+ * - Git-fallback for projectUuid: detectRepoName() → findProjectByRepoName(); NoProjectResolved if both fail
15
+ * - NotFound on unknown uuid returns isError:true
16
+ */
17
+ import { Logger } from '../utils/logger.js';
18
+ import { handleExternalServiceError } from '../utils/errors.js';
19
+ import { DebuggAIServerClient } from '../services/index.js';
20
+ import { config } from '../config/index.js';
21
+ import { detectRepoName } from '../utils/gitContext.js';
22
+ import { toPaginationParams, makePageInfo } from '../utils/pagination.js';
23
+ const logger = new Logger({ module: 'searchEnvironmentsHandler' });
24
+ function stripPassword(cred) {
25
+ // Defensive: take only known-safe keys. Never spread the source.
26
+ return {
27
+ uuid: cred.uuid,
28
+ label: cred.label,
29
+ username: cred.username,
30
+ role: cred.role ?? null,
31
+ ...(cred.environmentUuid ? { environmentUuid: cred.environmentUuid } : {}),
32
+ };
33
+ }
34
+ function notFound(uuid) {
35
+ return {
36
+ content: [{
37
+ type: 'text',
38
+ text: JSON.stringify({ error: 'NotFound', message: `Environment ${uuid} not found.`, uuid }, null, 2),
39
+ }],
40
+ isError: true,
41
+ };
42
+ }
43
+ function noProjectResolved(pagination, reason) {
44
+ return {
45
+ content: [{
46
+ type: 'text',
47
+ text: JSON.stringify({
48
+ error: 'NoProjectResolved',
49
+ message: reason,
50
+ pageInfo: makePageInfo(pagination.page, pagination.pageSize, 0, null),
51
+ environments: [],
52
+ }, null, 2),
53
+ }],
54
+ };
55
+ }
56
+ export async function searchEnvironmentsHandler(input, _context) {
57
+ const start = Date.now();
58
+ const pagination = toPaginationParams({ page: input.page, pageSize: input.pageSize });
59
+ logger.toolStart('search_environments', { ...input, ...pagination });
60
+ try {
61
+ const client = new DebuggAIServerClient(config.api.key);
62
+ await client.init();
63
+ // ── Resolve projectUuid ──
64
+ let projectUuid = input.projectUuid;
65
+ let project = null;
66
+ if (!projectUuid) {
67
+ const repoName = detectRepoName();
68
+ if (!repoName) {
69
+ return noProjectResolved(pagination, 'No git repo detected and no projectUuid provided. Pass projectUuid (get via search_projects) or invoke from a directory with a git origin.');
70
+ }
71
+ const resolved = await client.findProjectByRepoName(repoName);
72
+ if (!resolved) {
73
+ return noProjectResolved(pagination, `No DebuggAI project found for repo "${repoName}". Pass projectUuid explicitly.`);
74
+ }
75
+ projectUuid = resolved.uuid;
76
+ project = { uuid: resolved.uuid, name: resolved.name, repoName: resolved.repo?.name ?? repoName };
77
+ }
78
+ else {
79
+ project = { uuid: projectUuid, name: null, repoName: null };
80
+ }
81
+ // ── uuid mode ──
82
+ if (input.uuid) {
83
+ try {
84
+ const env = await client.getEnvironment(projectUuid, input.uuid);
85
+ const creds = await client.listCredentialsForEnvironment(projectUuid, input.uuid).catch(() => []);
86
+ const payload = {
87
+ project,
88
+ filter: { uuid: input.uuid },
89
+ pageInfo: { page: 1, pageSize: 1, totalCount: 1, totalPages: 1, hasMore: false },
90
+ environments: [{ ...env, credentials: creds.map(stripPassword) }],
91
+ };
92
+ logger.toolComplete('search_environments', Date.now() - start);
93
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
94
+ }
95
+ catch (err) {
96
+ if (err?.statusCode === 404 || err?.response?.status === 404)
97
+ return notFound(input.uuid);
98
+ throw err;
99
+ }
100
+ }
101
+ // ── Filter mode ──
102
+ const { pageInfo, environments } = await client.listEnvironmentsPaginated(projectUuid, pagination, input.q);
103
+ // Expand creds per env (sequential — bounded by page size, typically ≤20)
104
+ const withCreds = [];
105
+ for (const env of environments) {
106
+ const creds = await client.listCredentialsForEnvironment(projectUuid, env.uuid).catch(() => []);
107
+ withCreds.push({ ...env, credentials: creds.map(stripPassword) });
108
+ }
109
+ const payload = {
110
+ project,
111
+ filter: { q: input.q ?? null },
112
+ pageInfo,
113
+ environments: withCreds,
114
+ };
115
+ logger.toolComplete('search_environments', Date.now() - start);
116
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
117
+ }
118
+ catch (error) {
119
+ logger.toolError('search_environments', error, Date.now() - start);
120
+ throw handleExternalServiceError(error, 'DebuggAI', 'search_environments');
121
+ }
122
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * search_executions handler (bead 49b)
3
+ *
4
+ * Absorbs list_executions + get_execution.
5
+ *
6
+ * Modes:
7
+ * uuid: {uuid} → {filter:{uuid}, pageInfo:{totalCount:1,...}, executions:[fullDetail]}
8
+ * fullDetail includes nodeExecutions, state, errorInfo.
9
+ * filter: {status?, projectUuid?, page?, pageSize?} → {filter, pageInfo, executions:[summary]}
10
+ */
11
+ import { Logger } from '../utils/logger.js';
12
+ import { handleExternalServiceError } from '../utils/errors.js';
13
+ import { DebuggAIServerClient } from '../services/index.js';
14
+ import { config } from '../config/index.js';
15
+ import { toPaginationParams } from '../utils/pagination.js';
16
+ const logger = new Logger({ module: 'searchExecutionsHandler' });
17
+ function notFound(uuid) {
18
+ return {
19
+ content: [{
20
+ type: 'text',
21
+ text: JSON.stringify({ error: 'NotFound', message: `Execution ${uuid} not found.`, uuid }, null, 2),
22
+ }],
23
+ isError: true,
24
+ };
25
+ }
26
+ export async function searchExecutionsHandler(input, _context) {
27
+ const start = Date.now();
28
+ logger.toolStart('search_executions', input);
29
+ try {
30
+ const client = new DebuggAIServerClient(config.api.key);
31
+ await client.init();
32
+ if (input.uuid) {
33
+ try {
34
+ const execution = await client.workflows.getExecution(input.uuid);
35
+ const payload = {
36
+ filter: { uuid: input.uuid },
37
+ pageInfo: { page: 1, pageSize: 1, totalCount: 1, totalPages: 1, hasMore: false },
38
+ executions: [execution],
39
+ };
40
+ logger.toolComplete('search_executions', Date.now() - start);
41
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
42
+ }
43
+ catch (err) {
44
+ if (err?.statusCode === 404 || err?.response?.status === 404)
45
+ return notFound(input.uuid);
46
+ throw err;
47
+ }
48
+ }
49
+ const pagination = toPaginationParams({ page: input.page, pageSize: input.pageSize });
50
+ const { pageInfo, executions } = await client.workflows.listExecutions({
51
+ status: input.status,
52
+ projectId: input.projectUuid,
53
+ page: pagination.page,
54
+ pageSize: pagination.pageSize,
55
+ });
56
+ const payload = {
57
+ filter: {
58
+ status: input.status ?? null,
59
+ projectUuid: input.projectUuid ?? null,
60
+ },
61
+ pageInfo,
62
+ executions,
63
+ };
64
+ logger.toolComplete('search_executions', Date.now() - start);
65
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
66
+ }
67
+ catch (error) {
68
+ logger.toolError('search_executions', error, Date.now() - start);
69
+ throw handleExternalServiceError(error, 'DebuggAI', 'search_executions');
70
+ }
71
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * search_projects handler (bead ue3)
3
+ *
4
+ * Single tool covering both uuid-lookup and filter/list modes.
5
+ *
6
+ * Modes:
7
+ * uuid mode: {uuid} → {filter:{uuid}, pageInfo:{totalCount:1,...}, projects:[fullProject]}
8
+ * — NotFound surfaces as isError:true
9
+ * filter mode: {q?, page?, pageSize?} → {filter:{q}, pageInfo, projects:[summaries]}
10
+ *
11
+ * Response shape is uniform ({filter, pageInfo, projects}) but `projects[0]`
12
+ * richness differs by mode: uuid-mode returns the full project (all backend
13
+ * keys), filter-mode returns a summary (uuid/name/slug/repoName).
14
+ */
15
+ import { Logger } from '../utils/logger.js';
16
+ import { handleExternalServiceError } from '../utils/errors.js';
17
+ import { DebuggAIServerClient } from '../services/index.js';
18
+ import { config } from '../config/index.js';
19
+ import { toPaginationParams } from '../utils/pagination.js';
20
+ const logger = new Logger({ module: 'searchProjectsHandler' });
21
+ function notFound(uuid) {
22
+ return {
23
+ content: [{
24
+ type: 'text',
25
+ text: JSON.stringify({ error: 'NotFound', message: `Project ${uuid} not found.`, uuid }, null, 2),
26
+ }],
27
+ isError: true,
28
+ };
29
+ }
30
+ export async function searchProjectsHandler(input, _context) {
31
+ const start = Date.now();
32
+ logger.toolStart('search_projects', input);
33
+ try {
34
+ const client = new DebuggAIServerClient(config.api.key);
35
+ await client.init();
36
+ if (input.uuid) {
37
+ try {
38
+ const project = await client.getProject(input.uuid);
39
+ const payload = {
40
+ filter: { uuid: input.uuid },
41
+ pageInfo: { page: 1, pageSize: 1, totalCount: 1, totalPages: 1, hasMore: false },
42
+ projects: [project],
43
+ };
44
+ logger.toolComplete('search_projects', Date.now() - start);
45
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
46
+ }
47
+ catch (err) {
48
+ if (err?.statusCode === 404 || err?.response?.status === 404)
49
+ return notFound(input.uuid);
50
+ throw err;
51
+ }
52
+ }
53
+ const pagination = toPaginationParams({ page: input.page, pageSize: input.pageSize });
54
+ const { pageInfo, projects } = await client.listProjects(pagination, input.q);
55
+ const payload = {
56
+ filter: { q: input.q ?? null },
57
+ pageInfo,
58
+ projects: projects.map((p) => ({
59
+ uuid: p.uuid,
60
+ name: p.name,
61
+ slug: p.slug,
62
+ repoName: p.repo?.name ?? null,
63
+ })),
64
+ };
65
+ logger.toolComplete('search_projects', Date.now() - start);
66
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
67
+ }
68
+ catch (error) {
69
+ logger.toolError('search_projects', error, Date.now() - start);
70
+ throw handleExternalServiceError(error, 'DebuggAI', 'search_projects');
71
+ }
72
+ }
@@ -43,16 +43,40 @@ export async function testPageChangesHandler(input, context, progressCallback) {
43
43
  releaseSlot();
44
44
  }
45
45
  }
46
- async function testPageChangesHandlerInner(input, context, progressCallback) {
46
+ async function testPageChangesHandlerInner(input, context, rawProgressCallback) {
47
47
  const startTime = Date.now();
48
48
  logger.toolStart('check_app_in_browser', input);
49
+ // Bead 0bq: wrap the progress callback in a circuit-breaker so a single
50
+ // client-side rejection of a stale progressToken (which would normally
51
+ // throw up the stack and abort the handler, or — worse — arrive post-response
52
+ // and tear down the stdio transport) is swallowed and disables further
53
+ // emissions in this request.
54
+ let progressDisabled = false;
55
+ const progressCallback = rawProgressCallback
56
+ ? async (update) => {
57
+ if (progressDisabled)
58
+ return;
59
+ try {
60
+ await rawProgressCallback(update);
61
+ }
62
+ catch (err) {
63
+ progressDisabled = true;
64
+ logger.warn('Progress emission failed; disabling further emissions for this request', {
65
+ error: err instanceof Error ? err.message : String(err),
66
+ });
67
+ }
68
+ }
69
+ : undefined;
49
70
  const client = new DebuggAIServerClient(config.api.key);
50
71
  await client.init();
51
72
  const originalUrl = resolveTargetUrl(input);
52
73
  let ctx = buildContext(originalUrl);
53
74
  let keyId;
54
75
  const abortController = new AbortController();
55
- const onStdinClose = () => abortController.abort();
76
+ const onStdinClose = () => {
77
+ abortController.abort();
78
+ progressDisabled = true; // client is gone — stop emitting
79
+ };
56
80
  process.stdin.once('close', onStdinClose);
57
81
  // Progress budget: 3 setup steps + 25 execution steps = 28 total
58
82
  const SETUP_STEPS = 3;
@@ -168,6 +192,7 @@ async function testPageChangesHandlerInner(input, context, progressCallback) {
168
192
  const BACKEND_SETUP_END = 6;
169
193
  let lastStepsTaken = 0;
170
194
  let observedMaxSteps = MAX_EXEC_STEPS;
195
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
171
196
  const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
172
197
  // Keep the tunnel alive while the workflow is actively running
173
198
  if (ctx.tunnelId)
@@ -180,6 +205,19 @@ async function testPageChangesHandlerInner(input, context, progressCallback) {
180
205
  }
181
206
  if (!progressCallback)
182
207
  return;
208
+ // Bead 0bq: emit the final "Complete:" progress INSIDE this callback
209
+ // when terminal status is detected. pollExecution will return on the
210
+ // next line (line 183 in services/workflows.ts), so there's no
211
+ // post-pollExecution progress emission that could race the response.
212
+ if (TERMINAL_STATUSES.has(exec.status)) {
213
+ const terminalOutcome = exec.state?.outcome ?? exec.status;
214
+ await progressCallback({
215
+ progress: TOTAL_STEPS,
216
+ total: TOTAL_STEPS,
217
+ message: `Complete: ${terminalOutcome}`,
218
+ });
219
+ return;
220
+ }
183
221
  // --- Compute progress number ---
184
222
  let execProgress;
185
223
  let message;
@@ -308,9 +346,12 @@ async function testPageChangesHandlerInner(input, context, progressCallback) {
308
346
  responsePayload.surferOutput = sanitizeResponseUrls(surferNode.outputData, ctx);
309
347
  }
310
348
  logger.toolComplete('check_app_in_browser', duration);
311
- if (progressCallback) {
312
- await progressCallback({ progress: TOTAL_STEPS, total: TOTAL_STEPS, message: `Complete: ${outcome}` });
313
- }
349
+ // NOTE (bead 0bq): the final "Complete:" progress is emitted INSIDE
350
+ // pollExecution's onUpdate when terminal status is detected see the
351
+ // TERMINAL_STATUSES block above. Emitting it here (post-resolve) creates
352
+ // a race where the progress can arrive AFTER the response on the wire,
353
+ // making the client reject it as an unknown progressToken and close the
354
+ // transport, breaking ALL subsequent tool calls.
314
355
  // Sanitize the whole payload so no tunnel URL leaks anywhere — including
315
356
  // agent-authored strings in actionTrace[*].intent, evaluation.reason, etc.
316
357
  const sanitizedPayload = sanitizeResponseUrls(responsePayload, ctx);