@inflexa-ai/harness 0.22.0 → 0.23.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.
@@ -38,7 +38,7 @@ import type { ThreadStore } from "../memory/thread-store.js";
38
38
  import type { ReferenceResolver } from "../report-model/reference-resolver.js";
39
39
  import type { ReportVersionStore } from "../state/report-versions.js";
40
40
  import type { ReportSessionStateGateway } from "../tools/report-authoring/authoring-tools.js";
41
- import { type ResolvePageAsset, type ResolvePageUrl, type SessionPagePublisher } from "../tools/report-session/index.js";
41
+ import { type MakeSessionPagePublisher, type ResolvePageAsset, type ResolvePageUrl } from "../tools/report-session/index.js";
42
42
  /** Canonical agent id -- distinct from the id of the old templating agent. */
43
43
  export declare const REPORT_SESSION_AGENT_ID: "report-session";
44
44
  /**
@@ -99,15 +99,18 @@ export interface ReportSessionAgentDeps {
99
99
  */
100
100
  readonly resolvePageAsset?: ResolvePageAsset;
101
101
  /**
102
- * Session-page publisher -- the hosted view of the rendered page. Bound, the preview
103
- * tool mints one grant after the page lands and attaches the URL beside the path.
104
- * Omitted, the result carries the page path alone.
102
+ * Session-page publisher factory -- the hosted view of the rendered page. The factory
103
+ * binds one analysis and the auth of the call, like `makeResolver`, thus the mint runs
104
+ * under the credential of the caller. Bound, the preview tool builds the publisher over
105
+ * the scope of the call, mints one grant after the page lands, and attaches the URL
106
+ * beside the path. Omitted, the result carries the page path alone.
105
107
  */
106
- readonly sessionPages?: SessionPagePublisher;
108
+ readonly makeSessionPages?: MakeSessionPagePublisher;
107
109
  /**
108
110
  * Page-URL formation of the eyes -- the URL that one look opens. A composition whose
109
- * browser cannot reach the workspace tree binds a resolver that names a served URL.
110
- * Omitted, the eyes tool navigates through a `file://` URL of the page path.
111
+ * browser cannot reach the workspace tree binds a resolver that names a served URL, and
112
+ * the args carry the auth of the tool call beside the page identity. Omitted, the eyes
113
+ * tool navigates through a `file://` URL of the page path.
111
114
  */
112
115
  readonly resolvePageUrl?: ResolvePageUrl;
113
116
  /** Operational logging seam; omitted falls back to no-op. */
@@ -39,7 +39,7 @@ const REPORT_SESSION_MAX_ITERATIONS = 75;
39
39
  /** Build the report `AgentDefinition` with every tool bound to its deps. */
40
40
  export function createReportSessionAgent(deps) {
41
41
  const { model, pool, embedding, workspaceFs, gateway, resolveWorkspaceRoot, store, threads, chrome, eyes, makeResolver, resolvePageAsset, logger } = deps;
42
- const { derivations, sandboxClient, runAuthorizer, sessionPages, resolvePageUrl } = deps;
42
+ const { derivations, sandboxClient, runAuthorizer, makeSessionPages, resolvePageUrl } = deps;
43
43
  const authoring = createReportAuthoringTools(gateway);
44
44
  const tools = [
45
45
  // The read surface toward the analysis. It roams the tree read-only.
@@ -83,7 +83,7 @@ export function createReportSessionAgent(deps) {
83
83
  resolveWorkspaceRoot,
84
84
  ...(makeResolver ? { makeResolver } : {}),
85
85
  ...(resolvePageAsset ? { resolvePageAsset } : {}),
86
- ...(sessionPages ? { sessionPages } : {}),
86
+ ...(makeSessionPages ? { makeSessionPages } : {}),
87
87
  ...(logger ? { logger } : {}),
88
88
  }),
89
89
  // The eyes tool. It opens the rendered page in headless Chrome.
@@ -75,6 +75,7 @@ export async function prepareChatTurn(deps, params) {
75
75
  runActivityContext,
76
76
  history,
77
77
  workingMemory: createWorkingMemory(pool),
78
+ ...(deps.logger ? { logger: deps.logger } : {}),
78
79
  });
79
80
  return { kind: "ok", threadType, messages, userMessage };
80
81
  }
@@ -35,6 +35,7 @@
35
35
  * thread store.
36
36
  */
37
37
  import type { ModelMessage } from "ai";
38
+ import type { Logger } from "../lib/logger.js";
38
39
  import type { LoopMessage } from "../loop/types.js";
39
40
  import type { ThreadHistory } from "../memory/thread-history.js";
40
41
  import type { ThreadType } from "../memory/thread-store.js";
@@ -64,6 +65,8 @@ export interface AssembleMessagesArgs {
64
65
  readonly workingMemory: WorkingMemoryStore;
65
66
  /** History-window token budget. Defaults to {@link DEFAULT_HISTORY_TOKEN_BUDGET}. */
66
67
  readonly tokenBudget?: number;
68
+ /** Diagnostic seam for the history repair record; omitted falls back to no-op. */
69
+ readonly logger?: Logger;
67
70
  }
68
71
  export interface AssembledMessages {
69
72
  /** The full message array for `runAgent`. */
@@ -34,7 +34,9 @@
34
34
  * tail injections (ephemeral, re-derived each turn) are never written to the
35
35
  * thread store.
36
36
  */
37
+ import { createNoopLogger } from "../lib/console-logger.js";
37
38
  import { unwrapOrThrow } from "../lib/result.js";
39
+ import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
38
40
  import { normalizeUnicode, redactSecrets } from "../input-sanitization.js";
39
41
  /**
40
42
  * Token budget for the `loadRecent` history window. Sized to leave headroom
@@ -49,6 +51,21 @@ export const DEFAULT_HISTORY_TOKEN_BUDGET = 120_000;
49
51
  export async function assembleMessages(args) {
50
52
  const budget = args.tokenBudget ?? DEFAULT_HISTORY_TOKEN_BUDGET;
51
53
  const history = unwrapOrThrow(await args.history.loadRecent(args.threadId, budget, { keepFirstTurn: args.threadType === "report" }));
54
+ // Read-side safety net for the wire contract: every tool call carries a
55
+ // result. The loop upholds it at every exit, but the store outlives any one
56
+ // writer — a row an older build wrote, or a hand-edited database, can carry
57
+ // an unanswered call, and the provider boundary then refuses the WHOLE
58
+ // transcript on every turn: the thread is wedged forever. Stripping the
59
+ // orphan here degrades one message instead. The warning is the only account
60
+ // a reader gets that the stored thread and the assembled one differ.
61
+ const repaired = stripUnansweredToolCalls(history);
62
+ if (repaired.length > 0) {
63
+ (args.logger ?? createNoopLogger()).named("assembly").warn("unanswered tool calls stripped from thread history", {
64
+ threadId: args.threadId,
65
+ toolCallIds: repaired.map((d) => d.toolCallId),
66
+ tools: repaired.map((d) => d.toolName),
67
+ });
68
+ }
52
69
  // Sanitization — applied once, here, to the new user input only.
53
70
  const userMessage = {
54
71
  role: "user",
@@ -359,4 +359,20 @@ export interface DataPreviewFailedPart {
359
359
  /** Optional taxonomy of failure kinds. */
360
360
  errorKind?: "render" | "submit" | "build" | "timeout" | "internal";
361
361
  }
362
- export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepUsagePart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart;
362
+ /**
363
+ * The record that a turn started a report session. It rides the chat stream
364
+ * live, and it persists into the turn at the position of the spawn.
365
+ *
366
+ * The part is a placement record and a freshness signal only. The thread
367
+ * store is the authority for the session: its existence, its title, and its
368
+ * archived state. When the thread of the part is archived or absent, a
369
+ * consumer renders nothing for it.
370
+ */
371
+ export interface ReportSessionStartedPart {
372
+ type: "data-report-session-started";
373
+ /** The id of the report thread that the spawn made. */
374
+ threadId: string;
375
+ /** The id of the conversation thread that spawned it. */
376
+ parentThreadId: string;
377
+ }
378
+ export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepUsagePart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart | ReportSessionStartedPart;
@@ -2,7 +2,7 @@ export { ORGAN_SYSTEMS, OrganSystemSchema, ORGAN_SYSTEM_LABELS, organSystemLabel
2
2
  export type { OrganSystem } from "./organ-system.js";
3
3
  export { SEVERITIES, SeveritySchema } from "./severity.js";
4
4
  export type { Severity } from "./severity.js";
5
- export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepUsagePart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, CortexChatPart, } from "./chat-parts.js";
5
+ export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepUsagePart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, ReportSessionStartedPart, CortexChatPart, } from "./chat-parts.js";
6
6
  export type { EventSource, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, FinishEvent, ChatErrorEvent, CortexChatEvent } from "./chat-events.js";
7
7
  export type { TokenUsageRollup } from "./usage.js";
8
8
  export { DATA_PROFILE_RUN_LITERAL } from "./data-profile.js";
@@ -18,6 +18,7 @@ export const PART_REGISTRY = {
18
18
  "data-run-failed": { emitter: "workflow", consumer: "sidebar", transient: false, reconciling: false },
19
19
  "data-report-preview": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
20
20
  "data-report-preview-failed": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
21
+ "data-report-session-started": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
21
22
  };
22
23
  export function isTransient(type) {
23
24
  return PART_REGISTRY[type].transient;
@@ -467,6 +467,11 @@ export declare const DataPreviewFailedPartSchema: z.ZodObject<{
467
467
  internal: "internal";
468
468
  }>>;
469
469
  }, z.core.$strip>;
470
+ export declare const ReportSessionStartedPartSchema: z.ZodObject<{
471
+ type: z.ZodLiteral<"data-report-session-started">;
472
+ threadId: z.ZodString;
473
+ parentThreadId: z.ZodString;
474
+ }, z.core.$strip>;
470
475
  export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
471
476
  type: z.ZodLiteral<"data-presentation">;
472
477
  id: z.ZodString;
@@ -763,4 +768,8 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
763
768
  build: "build";
764
769
  internal: "internal";
765
770
  }>>;
771
+ }, z.core.$strip>, z.ZodObject<{
772
+ type: z.ZodLiteral<"data-report-session-started">;
773
+ threadId: z.ZodString;
774
+ parentThreadId: z.ZodString;
766
775
  }, z.core.$strip>], "type">;
@@ -294,6 +294,12 @@ export const DataPreviewFailedPartSchema = z.object({
294
294
  reason: z.string(),
295
295
  errorKind: z.enum(["render", "submit", "build", "timeout", "internal"]).optional(),
296
296
  });
297
+ // ── Report Session Started ─────────────────────────────────────────
298
+ export const ReportSessionStartedPartSchema = z.object({
299
+ type: z.literal("data-report-session-started"),
300
+ threadId: z.string(),
301
+ parentThreadId: z.string(),
302
+ });
297
303
  // ── Union ───────────────────────────────────────────────────────────
298
304
  export const CortexChatPartSchema = z.discriminatedUnion("type", [
299
305
  PresentationPartSchema,
@@ -315,4 +321,5 @@ export const CortexChatPartSchema = z.discriminatedUnion("type", [
315
321
  RunFailedPartSchema,
316
322
  PreviewPartSchema,
317
323
  DataPreviewFailedPartSchema,
324
+ ReportSessionStartedPartSchema,
318
325
  ]);
package/dist/index.d.ts CHANGED
@@ -36,7 +36,7 @@ export type { ProvenanceCollector } from "./provenance/collector.js";
36
36
  export { UnavailablePreviewPublisher } from "./tools/report/preview-publisher.js";
37
37
  export type { PreviewPublisher, PreviewMintResult } from "./tools/report/preview-publisher.js";
38
38
  export { UnavailableSessionPagePublisher } from "./tools/report-session/session-page-publisher.js";
39
- export type { SessionPagePublisher, SessionPageMintResult } from "./tools/report-session/session-page-publisher.js";
39
+ export type { MakeSessionPagePublisher, SessionPagePublisher, SessionPageMintResult } from "./tools/report-session/session-page-publisher.js";
40
40
  export type { ResolvePageUrl, SessionPageAccess } from "./tools/report-session/index.js";
41
41
  export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
42
42
  export type { RunLauncher, LaunchOptions } from "./execution/run-launcher.js";
package/dist/index.js CHANGED
@@ -44,8 +44,10 @@ export { createNoopArtifactRegistry } from "./execution/noop-artifact-registry.j
44
44
  export { UnavailablePreviewPublisher } from "./tools/report/preview-publisher.js";
45
45
  // Seam: the hosted view of a report-session page. A managed host serves the URL space
46
46
  // `report-sessions/{analysisId}/{threadId}` and mints access through its own publisher,
47
- // thus the seam belongs on the front door. The URL seam of the eyes rides beside it: a
48
- // host whose browser cannot reach the workspace tree binds a served URL for the look.
47
+ // thus the seam belongs on the front door. Both seams bind per call over the auth of the
48
+ // tool call, thus a host mints under the credential of the caller and holds no ambient
49
+ // state. The URL seam of the eyes rides beside the publisher factory: a host whose
50
+ // browser cannot reach the workspace tree binds a served URL for the look.
49
51
  export { UnavailableSessionPagePublisher } from "./tools/report-session/session-page-publisher.js";
50
52
  // Run launching.
51
53
  export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
@@ -5,6 +5,7 @@ import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
6
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
7
7
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
8
+ import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
8
9
  import { classifyProviderError } from "../providers/errors.js";
9
10
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
10
11
  import { resultStep } from "./run-step.js";
@@ -91,6 +92,28 @@ export async function runAgent(agent, initial, session, opts) {
91
92
  const logFinish = (level, reason, cappedOut) => {
92
93
  log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, toolCalls: toolCallCount, toolErrors: toolErrorCount, usage });
93
94
  };
95
+ /**
96
+ * Settle the transcript on the way out — called at every return. A reply can
97
+ * carry a complete tool call beside ANY finish reason, because the call
98
+ * streams before the stop reason arrives, and only the dispatching branches
99
+ * (`tool-calls`, a call-carrying `stop`, and `length`) run one. A call this
100
+ * run never dispatched is stripped
101
+ * rather than executed (a filtered or aborted reply must not act) or answered
102
+ * with an invented result (the model would read a fabrication as ground
103
+ * truth). Without the strip, the unanswered call violates the wire contract —
104
+ * every tool call carries a result — and the provider boundary then refuses
105
+ * the whole transcript on every later turn of the thread. Scoped past
106
+ * `initial`: the caller's prefix is not this run's to repair.
107
+ */
108
+ const settleTranscript = () => {
109
+ const droppedCalls = stripUnansweredToolCalls(messages, initial.length);
110
+ if (droppedCalls.length === 0)
111
+ return;
112
+ log.warn("undispatched tool calls stripped at run exit", {
113
+ toolCallIds: droppedCalls.map((d) => d.toolCallId),
114
+ tools: droppedCalls.map((d) => d.toolName),
115
+ });
116
+ };
94
117
  const usageRecorder = opts.usageRecorder ?? createNoopUsageRecorder();
95
118
  /**
96
119
  * Fold one completed call into both rollups and hand it to the recorder.
@@ -135,6 +158,7 @@ export async function runAgent(agent, initial, session, opts) {
135
158
  // lands in a dispatch round — after the concurrent siblings in that same round
136
159
  // have completed and been appended. Mirrors the clean-stop terminal path.
137
160
  const stopOnDenial = async (i) => {
161
+ settleTranscript();
138
162
  await emit({ type: "iteration", source, index: i, final: true });
139
163
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
140
164
  logFinish("warn", "denied", false);
@@ -195,6 +219,7 @@ export async function runAgent(agent, initial, session, opts) {
195
219
  return errored;
196
220
  };
197
221
  const stopOnResolved = async (i) => {
222
+ settleTranscript();
198
223
  await emit({ type: "iteration", source, index: i, final: true });
199
224
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
200
225
  return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
@@ -266,7 +291,19 @@ export async function runAgent(agent, initial, session, opts) {
266
291
  return stopOnResolved(i);
267
292
  continue;
268
293
  }
269
- if (reply.finishReason !== "tool-calls") {
294
+ // A local model behind an OpenAI-compatible server routinely labels a
295
+ // tool-calling reply `stop`. The calls are complete and the model waits
296
+ // on their results, thus the mislabeled round dispatches like a
297
+ // `tool-calls` one. Every other terminal keeps the strip: a filtered or
298
+ // aborted reply must not act.
299
+ const dispatchesRound = reply.finishReason === "tool-calls" || (reply.finishReason === "stop" && toolCalls.length > 0);
300
+ if (!dispatchesRound) {
301
+ settleTranscript();
302
+ // The settle can remove a call-only aborted partial whole; the
303
+ // interruption marker then rides the last assistant that survived,
304
+ // matching the no-output abort contract above.
305
+ if (reply.finishReason === "aborted")
306
+ markLastLoopAssistant(messages, initial.length);
270
307
  await emit({ type: "iteration", source, index: i, final: true });
271
308
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
272
309
  logFinish("info", reply.finishReason, false);
@@ -307,6 +344,7 @@ export async function runAgent(agent, initial, session, opts) {
307
344
  // exhausted its iterations, while the reason carries the abort.
308
345
  if (assistantHasContent(wrapUp.message))
309
346
  messages.push(wrapUp.message);
347
+ settleTranscript();
310
348
  markLastLoopAssistant(messages, initial.length);
311
349
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
312
350
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
@@ -314,6 +352,7 @@ export async function runAgent(agent, initial, session, opts) {
314
352
  return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries, ...finishUsage() } };
315
353
  }
316
354
  messages.push(wrapUp.message);
355
+ settleTranscript();
317
356
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
318
357
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
319
358
  logFinish("warn", "max_iterations", true);
@@ -15,7 +15,7 @@
15
15
  * reconstruction.
16
16
  */
17
17
  import { type DataUIPart, type UIMessage } from "ai";
18
- import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, RunCardPart } from "../contracts/chat-parts.js";
18
+ import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, ReportSessionStartedPart, RunCardPart } from "../contracts/chat-parts.js";
19
19
  import type { ToolCallOutcome } from "../contracts/chat-events.js";
20
20
  import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
21
21
  export declare const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
@@ -40,6 +40,7 @@ export type ConversationUIData = {
40
40
  ask: Payload<AskPart>;
41
41
  "report-preview": Payload<PreviewPart>;
42
42
  "report-preview-failed": Payload<DataPreviewFailedPart>;
43
+ "report-session-started": Payload<ReportSessionStartedPart>;
43
44
  };
44
45
  export interface ConversationDisplayMetadata {
45
46
  interrupted?: boolean;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { validateUIMessages } from "ai";
18
18
  import { z } from "zod";
19
- import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
19
+ import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, ReportSessionStartedPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
20
20
  import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
21
21
  export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
22
22
  const DisplayEnvelopeHeaderSchema = z.object({
@@ -56,6 +56,7 @@ const dataSchemas = {
56
56
  ask: AskPartSchema.omit({ type: true }).strict(),
57
57
  "report-preview": PreviewPartSchema.omit({ type: true }).strict(),
58
58
  "report-preview-failed": DataPreviewFailedPartSchema.omit({ type: true }).strict(),
59
+ "report-session-started": ReportSessionStartedPartSchema.omit({ type: true }).strict(),
59
60
  };
60
61
  export function envelopeDisplayMessages(messages) {
61
62
  if (messages.length === 0)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Transcript integrity for tool calls.
3
+ *
4
+ * The wire contract of every provider is that each tool call in an assistant
5
+ * message has a matching tool result. A transcript that violates it is refused
6
+ * WHOLE — the AI SDK throws at prompt conversion, and the Anthropic API answers
7
+ * 400 — so a single unanswered call makes a thread unable to take any further
8
+ * turn. An unanswered call is a reachable state, not a hypothetical: a reply
9
+ * can carry a complete tool call beside ANY finish reason, because the call
10
+ * streams before the stop reason arrives, and only a `tool-calls` (and, for its
11
+ * leading calls, a `length`) finish dispatches it.
12
+ *
13
+ * The repair is a STRIP, never an invented result. A call that was not
14
+ * dispatched did not happen, and a fabricated result would record an execution
15
+ * that did not occur — the model would read it as ground truth on the next
16
+ * turn. The prose of the message survives the strip; a message left with
17
+ * nothing the wire can render disappears entirely.
18
+ */
19
+ import type { ModelMessage } from "ai";
20
+ /** One tool call removed by {@link stripUnansweredToolCalls}, identified for the caller's diagnostic record. */
21
+ export interface DroppedToolCall {
22
+ readonly toolCallId: string;
23
+ readonly toolName: string;
24
+ }
25
+ /**
26
+ * Remove, in place, every tool-call part at or past `fromIndex` that has no
27
+ * matching tool result anywhere in `messages`. Returns the removed calls in
28
+ * transcript order — empty on the healthy path — so the caller can log what
29
+ * a reader of the stored thread would otherwise never learn.
30
+ *
31
+ * Results are matched by id across the whole array, not by adjacency: the loop
32
+ * appends a round's results directly after its assistant message, but a
33
+ * deferred-image user message can ride between rounds, and the stored shape is
34
+ * not this function's to assume.
35
+ *
36
+ * A message that keeps a text or file part survives the strip — refusal prose
37
+ * is the one account the reader has of a filtered reply. A message left with
38
+ * no such part (a call-only message, or reasoning beside the removed call) is
39
+ * removed whole: reasoning without the output it preceded is not replayable,
40
+ * and an empty assistant message is itself a wire violation.
41
+ *
42
+ * `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
43
+ * `initial.length` so it never rewrites the caller's prefix; the assembly
44
+ * repair passes nothing and covers the whole stored window.
45
+ */
46
+ export declare function stripUnansweredToolCalls(messages: ModelMessage[], fromIndex?: number): DroppedToolCall[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Transcript integrity for tool calls.
3
+ *
4
+ * The wire contract of every provider is that each tool call in an assistant
5
+ * message has a matching tool result. A transcript that violates it is refused
6
+ * WHOLE — the AI SDK throws at prompt conversion, and the Anthropic API answers
7
+ * 400 — so a single unanswered call makes a thread unable to take any further
8
+ * turn. An unanswered call is a reachable state, not a hypothetical: a reply
9
+ * can carry a complete tool call beside ANY finish reason, because the call
10
+ * streams before the stop reason arrives, and only a `tool-calls` (and, for its
11
+ * leading calls, a `length`) finish dispatches it.
12
+ *
13
+ * The repair is a STRIP, never an invented result. A call that was not
14
+ * dispatched did not happen, and a fabricated result would record an execution
15
+ * that did not occur — the model would read it as ground truth on the next
16
+ * turn. The prose of the message survives the strip; a message left with
17
+ * nothing the wire can render disappears entirely.
18
+ */
19
+ /**
20
+ * Whether this tool call needs a client-supplied result. A provider-executed
21
+ * call is answered by the provider inside the same assistant message, so the
22
+ * wire contract makes no demand on the client for it — the AI SDK's own
23
+ * missing-result validation skips these, and so does the strip.
24
+ */
25
+ function needsClientResult(part) {
26
+ return part.providerExecuted !== true;
27
+ }
28
+ /**
29
+ * Remove, in place, every tool-call part at or past `fromIndex` that has no
30
+ * matching tool result anywhere in `messages`. Returns the removed calls in
31
+ * transcript order — empty on the healthy path — so the caller can log what
32
+ * a reader of the stored thread would otherwise never learn.
33
+ *
34
+ * Results are matched by id across the whole array, not by adjacency: the loop
35
+ * appends a round's results directly after its assistant message, but a
36
+ * deferred-image user message can ride between rounds, and the stored shape is
37
+ * not this function's to assume.
38
+ *
39
+ * A message that keeps a text or file part survives the strip — refusal prose
40
+ * is the one account the reader has of a filtered reply. A message left with
41
+ * no such part (a call-only message, or reasoning beside the removed call) is
42
+ * removed whole: reasoning without the output it preceded is not replayable,
43
+ * and an empty assistant message is itself a wire violation.
44
+ *
45
+ * `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
46
+ * `initial.length` so it never rewrites the caller's prefix; the assembly
47
+ * repair passes nothing and covers the whole stored window.
48
+ */
49
+ export function stripUnansweredToolCalls(messages, fromIndex = 0) {
50
+ const resultIds = new Set();
51
+ for (const message of messages) {
52
+ if (message.role === "tool") {
53
+ for (const part of message.content) {
54
+ if (part.type === "tool-result")
55
+ resultIds.add(part.toolCallId);
56
+ }
57
+ }
58
+ else if (message.role === "assistant" && typeof message.content !== "string") {
59
+ for (const part of message.content) {
60
+ if (part.type === "tool-result")
61
+ resultIds.add(part.toolCallId);
62
+ }
63
+ }
64
+ }
65
+ const dropped = [];
66
+ // Backward, so a whole-message removal cannot shift an index this loop has
67
+ // yet to visit.
68
+ for (let idx = messages.length - 1; idx >= fromIndex; idx--) {
69
+ const message = messages[idx];
70
+ if (message.role !== "assistant" || typeof message.content === "string")
71
+ continue;
72
+ const removed = [];
73
+ for (const part of message.content) {
74
+ if (part.type === "tool-call" && needsClientResult(part) && !resultIds.has(part.toolCallId))
75
+ removed.push(part);
76
+ }
77
+ if (removed.length === 0)
78
+ continue;
79
+ // Prepended as a batch: the walk visits messages tail-first, but the
80
+ // caller's record reads in transcript order.
81
+ dropped.unshift(...removed.map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName })));
82
+ const removedParts = new Set(removed);
83
+ const kept = message.content.filter((part) => !removedParts.has(part));
84
+ if (kept.some((part) => part.type === "text" || part.type === "file" || part.type === "tool-call" || part.type === "tool-result")) {
85
+ messages[idx] = { ...message, content: kept };
86
+ }
87
+ else {
88
+ messages.splice(idx, 1);
89
+ }
90
+ }
91
+ return dropped;
92
+ }
@@ -32,7 +32,7 @@ import { type CitationResolverConfig } from "../citations/resolve.js";
32
32
  import type { CitationResolver } from "../citations/types.js";
33
33
  import type { AuthContext } from "../auth/types.js";
34
34
  import type { ReferenceResolver } from "../report-model/reference-resolver.js";
35
- import type { ResolvePageAsset, ResolvePageUrl, SessionPagePublisher } from "../tools/report-session/index.js";
35
+ import type { MakeSessionPagePublisher, ResolvePageAsset, ResolvePageUrl } from "../tools/report-session/index.js";
36
36
  /** Registered child sandbox-step callable the parent's child dispatch closes over. */
37
37
  export type SandboxStepCallable = (input: SandboxStepInput) => Promise<SandboxStepResult>;
38
38
  /**
@@ -134,15 +134,18 @@ export interface CoreRuntimeDeps {
134
134
  readonly resolveReportPageAsset?: ResolvePageAsset;
135
135
  /**
136
136
  * The hosted view of a report-session page. A managed host that serves the session URL
137
- * space binds its publisher here, and the preview tool of the report agent then attaches
138
- * the minted URL beside the page path. Absent, the tool attaches nothing and the page
139
- * path stays the whole result, which is the whole local path.
137
+ * space binds its publisher factory here, and the preview tool of the report agent then
138
+ * attaches the minted URL beside the page path. The factory binds one analysis and the
139
+ * auth of the tool call, like `makeReportReferenceResolver`, thus a host that mints under
140
+ * the credential of the caller binds it with no ambient state. Absent, the tool attaches
141
+ * nothing and the page path stays the whole result, which is the whole local path.
140
142
  */
141
- readonly sessionPagePublisher?: SessionPagePublisher;
143
+ readonly makeSessionPagePublisher?: MakeSessionPagePublisher;
142
144
  /**
143
145
  * The page-URL formation of the report-session eyes. A managed host whose browser cannot
144
- * reach the workspace tree binds a resolver that names a served URL for the look. Absent,
145
- * the eyes tool navigates through a `file://` URL of the page path.
146
+ * reach the workspace tree binds a resolver that names a served URL for the look, and the
147
+ * args carry the auth of the tool call beside the page identity. Absent, the eyes tool
148
+ * navigates through a `file://` URL of the page path.
146
149
  */
147
150
  readonly resolveReportPageUrl?: ResolvePageUrl;
148
151
  /**
@@ -164,7 +164,7 @@ export function assembleCoreRuntime(deps) {
164
164
  makeResolver: makeReportResolver,
165
165
  ...(eyes ? { eyes } : {}),
166
166
  ...(deps.resolveReportPageAsset ? { resolvePageAsset: deps.resolveReportPageAsset } : {}),
167
- ...(deps.sessionPagePublisher ? { sessionPages: deps.sessionPagePublisher } : {}),
167
+ ...(deps.makeSessionPagePublisher ? { makeSessionPages: deps.makeSessionPagePublisher } : {}),
168
168
  ...(deps.resolveReportPageUrl ? { resolvePageUrl: deps.resolveReportPageUrl } : {}),
169
169
  ...(conversation.logger ? { logger: conversation.logger } : {}),
170
170
  });
@@ -45,6 +45,7 @@
45
45
  * and no outcome would ever arrive.
46
46
  */
47
47
  import { z } from "zod";
48
+ import type { AuthContext } from "../../auth/types.js";
48
49
  import { type ChromeConfig } from "../../lib/chrome.js";
49
50
  import { type AcquireEyes } from "../../lib/eyes.js";
50
51
  import { type Logger } from "../../lib/logger.js";
@@ -56,11 +57,14 @@ export type { CapturePage, FailedRequest, PageCapture };
56
57
  /**
57
58
  * The URL formation of one look. It maps the page of the thread onto the URL that the browser opens.
58
59
  * A composition whose browser cannot reach the workspace tree binds a resolver that names a served URL.
60
+ * The args carry the auth of the tool call beside the page identity, thus a realization mints the URL
61
+ * under the credential of the caller and holds no ambient state.
59
62
  */
60
63
  export type ResolvePageUrl = (args: {
61
64
  pagePath: string;
62
65
  analysisId: string;
63
66
  threadId: string;
67
+ auth: AuthContext;
64
68
  }) => Promise<string>;
65
69
  /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
66
70
  declare const examinePageInput: z.ZodObject<{}, z.core.$strip>;
@@ -276,7 +276,10 @@ export function createExaminePageTool(deps) {
276
276
  // becomes a typed outcome and the look never starts.
277
277
  let url;
278
278
  try {
279
- url = deps.resolvePageUrl !== undefined ? await deps.resolvePageUrl({ pagePath, analysisId, threadId }) : pathToFileURL(pagePath).href;
279
+ url =
280
+ deps.resolvePageUrl !== undefined
281
+ ? await deps.resolvePageUrl({ pagePath, analysisId, threadId, auth: ctx.session.auth })
282
+ : pathToFileURL(pagePath).href;
280
283
  }
281
284
  catch (cause) {
282
285
  logger.warn("the page URL did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps, type ResolvePageAsset, type SessionPageAccess, } from "./preview-report.js";
9
9
  export { createExaminePageTool, type CapturePage, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, type ResolvePageUrl, } from "./examine-page.js";
10
- export { describeSessionPageMintFailure, UnavailableSessionPagePublisher, type SessionPageMintFailure, type SessionPageMintResult, type SessionPagePublisher, } from "./session-page-publisher.js";
10
+ export { describeSessionPageMintFailure, UnavailableSessionPagePublisher, type MakeSessionPagePublisher, type SessionPageMintFailure, type SessionPageMintResult, type SessionPagePublisher, } from "./session-page-publisher.js";
11
11
  export { createRecordVersionTool, type RecordVersionInput, type RecordVersionResult, type RecordVersionToolDeps } from "./record-version.js";
12
12
  export { createDeriveTableTool, type DeriveTableInput, type DeriveTableResult, type DeriveTableToolDeps } from "./derive-table.js";
13
13
  export { createListPinnedArtifactsTool, type ListPinnedArtifactsInput, type ListPinnedArtifactsResult, type ListPinnedArtifactsToolDeps, type PinnedArtifact, type PinnedCitation, } from "./list-artifacts.js";
@@ -16,11 +16,12 @@
16
16
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
17
17
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
18
18
  *
19
- * A hosted view rides the same result. When the composition binds the session-page publisher, the tool
20
- * mints one grant after the page lands, and the `rendered` arm carries the URL of the URL space
21
- * `report-sessions/{analysisId}/{threadId}` (`contracts/content-url.ts`) beside the path. A refused mint
22
- * rides the arm as data, and the page path stays good — a broken grant surface never costs the render. An
23
- * unbound publisher changes nothing: the arm carries no access field, and a local host opens the file.
19
+ * A hosted view rides the same result. When the composition binds the session-page factory, the tool
20
+ * builds the publisher over the scope of the call, mints one grant after the page lands, and the
21
+ * `rendered` arm carries the URL of the URL space `report-sessions/{analysisId}/{threadId}`
22
+ * (`contracts/content-url.ts`) beside the path. A refused mint rides the arm as data, and the page path
23
+ * stays good — a broken grant surface never costs the render. An unbound factory changes nothing: the
24
+ * arm carries no access field, and a local host opens the file.
24
25
  *
25
26
  * Each degraded condition is a typed outcome in the ok channel: a session refusal, a gap list, a resolver
26
27
  * absence, an unresolvable root at resolver construction, an unresolved reference, a bridge mismatch, a
@@ -43,7 +44,7 @@ import { type BridgeMismatch } from "../../report-render/value-bridge.js";
43
44
  import type { RenderProblem } from "../../report-render/types.js";
44
45
  import { type Tool } from "../define-tool.js";
45
46
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
46
- import { type SessionPagePublisher } from "./session-page-publisher.js";
47
+ import { type MakeSessionPagePublisher } from "./session-page-publisher.js";
47
48
  /** The empty input. The tool renders the current draft of the thread, thus it needs no field. */
48
49
  declare const previewReportInput: z.ZodObject<{}, z.core.$strip>;
49
50
  export type PreviewReportInput = z.infer<typeof previewReportInput>;
@@ -118,9 +119,11 @@ export type ResolvePageAsset = (specifier: string) => string;
118
119
  * harness. An embedder that ships the asset bytes packed, for example a compiled single-file binary with no
119
120
  * `node_modules` tree, materializes them to disk and binds its own lookup here.
120
121
  *
121
- * `sessionPages` is optional, because a local host opens the page path itself. Bound, the tool mints one
122
- * grant after the page lands and attaches the URL beside the path. A refused mint rides the `rendered` arm
123
- * as data, and it never fails the render.
122
+ * `makeSessionPages` is optional, because a local host opens the page path itself. It binds one analysis
123
+ * and the auth of the call, like `makeResolver`, thus a realization mints under the credential of the
124
+ * caller. Bound, the tool builds the publisher over the scope of the call, mints one grant after the page
125
+ * lands, and attaches the URL beside the path. A refused mint rides the `rendered` arm as data, and it
126
+ * never fails the render.
124
127
  */
125
128
  export interface PreviewReportToolDeps {
126
129
  readonly gateway: ReportSessionStateGateway;
@@ -130,7 +133,7 @@ export interface PreviewReportToolDeps {
130
133
  }) => ReferenceResolver;
131
134
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
132
135
  readonly resolvePageAsset?: ResolvePageAsset;
133
- readonly sessionPages?: SessionPagePublisher;
136
+ readonly makeSessionPages?: MakeSessionPagePublisher;
134
137
  readonly logger?: Logger;
135
138
  }
136
139
  /**
@@ -16,11 +16,12 @@
16
16
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
17
17
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
18
18
  *
19
- * A hosted view rides the same result. When the composition binds the session-page publisher, the tool
20
- * mints one grant after the page lands, and the `rendered` arm carries the URL of the URL space
21
- * `report-sessions/{analysisId}/{threadId}` (`contracts/content-url.ts`) beside the path. A refused mint
22
- * rides the arm as data, and the page path stays good — a broken grant surface never costs the render. An
23
- * unbound publisher changes nothing: the arm carries no access field, and a local host opens the file.
19
+ * A hosted view rides the same result. When the composition binds the session-page factory, the tool
20
+ * builds the publisher over the scope of the call, mints one grant after the page lands, and the
21
+ * `rendered` arm carries the URL of the URL space `report-sessions/{analysisId}/{threadId}`
22
+ * (`contracts/content-url.ts`) beside the path. A refused mint rides the arm as data, and the page path
23
+ * stays good — a broken grant surface never costs the render. An unbound factory changes nothing: the
24
+ * arm carries no access field, and a local host opens the file.
24
25
  *
25
26
  * Each degraded condition is a typed outcome in the ok channel: a session refusal, a gap list, a resolver
26
27
  * absence, an unresolvable root at resolver construction, an unresolved reference, a bridge mismatch, a
@@ -342,18 +343,20 @@ async function sweepAssets(assetsDir, staged, logger) {
342
343
  /**
343
344
  * Mint the hosted view of the page that landed.
344
345
  *
345
- * An absent publisher gives no grant, and the arm carries no access field — the page path stays the whole
346
- * local contract. A refusal and a thrown realization each become the not-granted arm, thus a broken grant
347
- * surface never costs the render. The URL spells through `buildReportSessionUrl`, thus the formula lives in
348
- * the contract and the seam gives the content-server base alone.
346
+ * An absent factory gives no grant, and the arm carries no access field — the page path stays the whole
347
+ * local contract. The tool builds the publisher over the scope of the call, thus the mint runs under the
348
+ * auth of the caller. A refusal, a thrown construction, and a thrown realization each become the
349
+ * not-granted arm, thus a broken grant surface never costs the render. The URL spells through
350
+ * `buildReportSessionUrl`, thus the formula lives in the contract and the seam gives the content-server
351
+ * base alone.
349
352
  */
350
- async function mintAccess(publisher, analysisId, threadId, logger) {
351
- if (publisher === undefined) {
353
+ async function mintAccess(makeSessionPages, analysisId, threadId, auth, logger) {
354
+ if (makeSessionPages === undefined) {
352
355
  return undefined;
353
356
  }
354
357
  let minted;
355
358
  try {
356
- minted = await publisher.mintSessionPageAccess(analysisId, threadId);
359
+ minted = await makeSessionPages({ analysisId, auth }).mintSessionPageAccess(threadId);
357
360
  }
358
361
  catch (cause) {
359
362
  logger.warn("the session-page mint threw", { threadId, analysisId, ...defaultErrorFields(cause) });
@@ -472,7 +475,7 @@ export function createPreviewReportTool(deps) {
472
475
  logger.warn("the rendered hash did not stamp", { threadId, analysisId, detail });
473
476
  return ok({ outcome: "stamp-failed", pagePath: written.value, detail });
474
477
  }
475
- const access = await mintAccess(deps.sessionPages, analysisId, threadId, logger);
478
+ const access = await mintAccess(deps.makeSessionPages, analysisId, threadId, ctx.session.auth, logger);
476
479
  return ok({ outcome: "rendered", pagePath: written.value, ...(access !== undefined ? { access } : {}) });
477
480
  },
478
481
  });
@@ -7,10 +7,14 @@
7
7
  * `report-sessions/{analysisId}/{threadId}` (`contracts/content-url.ts`). That mint hides behind this
8
8
  * one method.
9
9
  *
10
- * The managed realization owns the grant mint and its client. The local default
11
- * (`UnavailableSessionPagePublisher`) returns not-ok, thus the preview tool attaches no URL and the
12
- * render still lands — the page path stays the whole local contract.
10
+ * The managed realization owns the grant mint and its client. A mint runs under the credential of the
11
+ * tool call, thus the composition binds a factory and the preview tool builds the publisher over the
12
+ * scope of the call — exactly as it builds the reference resolver. A boot-time singleton could not
13
+ * carry that credential, and a host that bans ambient state could not bind one at all. The local
14
+ * default (`UnavailableSessionPagePublisher`) returns not-ok, thus the preview tool attaches no URL
15
+ * and the render still lands — the page path stays the whole local contract.
13
16
  */
17
+ import type { AuthContext } from "../../auth/types.js";
14
18
  export type SessionPageMintResult = {
15
19
  ok: true;
16
20
  data: {
@@ -40,16 +44,25 @@ export type SessionPageMintFailure = Extract<SessionPageMintResult, {
40
44
  export declare function describeSessionPageMintFailure(failure: SessionPageMintFailure): string;
41
45
  export interface SessionPagePublisher {
42
46
  /**
43
- * Mint the access grant of one session page. `baseUrl` is the base URL of the content server, with
44
- * no res path — the caller spells the whole URL through `buildReportSessionUrl`, thus the formula
45
- * lives in the contract and never in a realization.
47
+ * Mint the access grant of one session page. The publisher binds the analysis, thus the mint takes
48
+ * the thread id alone. `baseUrl` is the base URL of the content server, with no res path — the
49
+ * caller spells the whole URL through `buildReportSessionUrl`, thus the formula lives in the
50
+ * contract and never in a realization.
46
51
  */
47
- mintSessionPageAccess(analysisId: string, threadId: string): Promise<SessionPageMintResult>;
52
+ mintSessionPageAccess(threadId: string): Promise<SessionPageMintResult>;
48
53
  }
54
+ /**
55
+ * The per-call construction of the publisher. The factory binds one analysis and the auth of the tool
56
+ * call, thus a realization mints under the credential of the caller and holds no ambient state.
57
+ */
58
+ export type MakeSessionPagePublisher = (scope: {
59
+ analysisId: string;
60
+ auth: AuthContext;
61
+ }) => SessionPagePublisher;
49
62
  /**
50
63
  * Local default — no hosted view of a session page. Returns not-ok, thus the preview tool carries the
51
64
  * refusal as data and the page path stays the whole result.
52
65
  */
53
66
  export declare class UnavailableSessionPagePublisher implements SessionPagePublisher {
54
- mintSessionPageAccess(_analysisId: string, _threadId: string): Promise<SessionPageMintResult>;
67
+ mintSessionPageAccess(_threadId: string): Promise<SessionPageMintResult>;
55
68
  }
@@ -7,9 +7,12 @@
7
7
  * `report-sessions/{analysisId}/{threadId}` (`contracts/content-url.ts`). That mint hides behind this
8
8
  * one method.
9
9
  *
10
- * The managed realization owns the grant mint and its client. The local default
11
- * (`UnavailableSessionPagePublisher`) returns not-ok, thus the preview tool attaches no URL and the
12
- * render still lands — the page path stays the whole local contract.
10
+ * The managed realization owns the grant mint and its client. A mint runs under the credential of the
11
+ * tool call, thus the composition binds a factory and the preview tool builds the publisher over the
12
+ * scope of the call — exactly as it builds the reference resolver. A boot-time singleton could not
13
+ * carry that credential, and a host that bans ambient state could not bind one at all. The local
14
+ * default (`UnavailableSessionPagePublisher`) returns not-ok, thus the preview tool attaches no URL
15
+ * and the render still lands — the page path stays the whole local contract.
13
16
  */
14
17
  /**
15
18
  * Renders a failed mint as the line an agent reads back from the preview tool.
@@ -33,7 +36,7 @@ export function describeSessionPageMintFailure(failure) {
33
36
  * refusal as data and the page path stays the whole result.
34
37
  */
35
38
  export class UnavailableSessionPagePublisher {
36
- async mintSessionPageAccess(_analysisId, _threadId) {
39
+ async mintSessionPageAccess(_threadId) {
37
40
  return {
38
41
  ok: false,
39
42
  error: { message: "the hosted view of a session page is unavailable in this environment" },
@@ -167,13 +167,23 @@ export function createStartReportSessionTool(deps) {
167
167
  }
168
168
  }
169
169
  const spawned = await spawn.spawnReportSession(parentThreadId, brief);
170
- return ok(spawned.match((child) => ({ outcome: "started", threadId: child.threadId, title: child.title }), (fault) => {
171
- const outcome = toOutcome(fault);
172
- if (outcome.outcome === "failed") {
173
- logger.warn("the report session did not start", { parentThreadId, reason: fault.type, ...logger.errorFields(causeOf(fault)) });
174
- }
175
- return outcome;
176
- }));
170
+ if (spawned.isOk()) {
171
+ const child = spawned.value;
172
+ // The part rides the started arm only. The existing-session arm
173
+ // names a session that an earlier turn announced, thus a second
174
+ // part would put a second entry into the transcript.
175
+ await ctx.emit({
176
+ type: "data-report-session-started",
177
+ data: { threadId: child.threadId, parentThreadId },
178
+ });
179
+ return ok({ outcome: "started", threadId: child.threadId, title: child.title });
180
+ }
181
+ const fault = spawned.error;
182
+ const outcome = toOutcome(fault);
183
+ if (outcome.outcome === "failed") {
184
+ logger.warn("the report session did not start", { parentThreadId, reason: fault.type, ...logger.errorFields(causeOf(fault)) });
185
+ }
186
+ return ok(outcome);
177
187
  },
178
188
  });
179
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",