diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md
index 6093fbb39ed..54ff7036f14 100644
--- a/docs/generated/postgres-schema/README.md
+++ b/docs/generated/postgres-schema/README.md
@@ -83,7 +83,6 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.instance_ai_observations](public.instance_ai_observations.md) | 10 | | BASE TABLE |
| [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) | 12 | | BASE TABLE |
| [public.instance_ai_resources](public.instance_ai_resources.md) | 5 | | BASE TABLE |
-| [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) | 11 | | BASE TABLE |
| [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | 5 | | BASE TABLE |
| [public.instance_ai_threads](public.instance_ai_threads.md) | 7 | | BASE TABLE |
| [public.instance_ai_workflow_snapshots](public.instance_ai_workflow_snapshots.md) | 7 | | BASE TABLE |
@@ -279,7 +278,6 @@ erDiagram
"public.instance_ai_pending_confirmations" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--o| "public.instance_ai_checkpoints" : "FOREIGN KEY (#quot;checkpointKey#quot;) REFERENCES instance_ai_checkpoints(key) ON DELETE CASCADE"
-"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
@@ -1139,19 +1137,6 @@ erDiagram
timestamp_3__with_time_zone updatedAt
text workingMemory
}
-"public.instance_ai_run_snapshots" {
- timestamp_3__with_time_zone createdAt
- varchar_36_ langsmithRunId
- varchar_36_ langsmithTraceId
- varchar_36_ messageGroupId
- varchar_36_ runId
- json runIds
- varchar_64_ spanId
- uuid threadId FK
- varchar_64_ traceId
- text tree
- timestamp_3__with_time_zone updatedAt
-}
"public.instance_ai_thread_grants" {
timestamp_3__with_time_zone createdAt
varchar_512_ grantKey
diff --git a/docs/generated/postgres-schema/public.instance_ai_run_snapshots.md b/docs/generated/postgres-schema/public.instance_ai_run_snapshots.md
deleted file mode 100644
index bfa6db30318..00000000000
--- a/docs/generated/postgres-schema/public.instance_ai_run_snapshots.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# public.instance_ai_run_snapshots
-
-## Columns
-
-| Name | Type | Default | Nullable | Children | Parents | Comment |
-| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
-| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
-| langsmithRunId | varchar(36) | | true | | | LangSmith run ID (UUID v4, e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479"). |
-| langsmithTraceId | varchar(36) | | true | | | LangSmith trace ID (UUID v4, e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479"). |
-| messageGroupId | varchar(36) | | true | | | |
-| runId | varchar(36) | | false | | | |
-| runIds | json | | true | | | |
-| spanId | varchar(64) | | true | | | OpenTelemetry span ID for the root Instance AI run. |
-| threadId | uuid | | false | | [public.instance_ai_threads](public.instance_ai_threads.md) | |
-| traceId | varchar(64) | | true | | | OpenTelemetry trace ID for the root Instance AI run. |
-| tree | text | | false | | | |
-| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
-
-## Constraints
-
-| Name | Type | Definition |
-| ---- | ---- | ---------- |
-| FK_2f63fa21d09d7918f347ddbdf70 | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES instance_ai_threads(id) ON DELETE CASCADE |
-| PK_0a5fc9690a84950ebf1416fb146 | PRIMARY KEY | PRIMARY KEY ("threadId", "runId") |
-| instance_ai_run_snapshots_createdAt_not_null | n | NOT NULL "createdAt" |
-| instance_ai_run_snapshots_runId_not_null | n | NOT NULL "runId" |
-| instance_ai_run_snapshots_threadId_not_null | n | NOT NULL "threadId" |
-| instance_ai_run_snapshots_tree_not_null | n | NOT NULL tree |
-| instance_ai_run_snapshots_updatedAt_not_null | n | NOT NULL "updatedAt" |
-
-## Indexes
-
-| Name | Definition |
-| ---- | ---------- |
-| IDX_d3a2bc880e7a8626802e5474ad | CREATE INDEX "IDX_d3a2bc880e7a8626802e5474ad" ON public.instance_ai_run_snapshots USING btree ("threadId", "createdAt") |
-| IDX_d926c16c2ad9728cb9a81790c0 | CREATE INDEX "IDX_d926c16c2ad9728cb9a81790c0" ON public.instance_ai_run_snapshots USING btree ("threadId", "messageGroupId") |
-| PK_0a5fc9690a84950ebf1416fb146 | CREATE UNIQUE INDEX "PK_0a5fc9690a84950ebf1416fb146" ON public.instance_ai_run_snapshots USING btree ("threadId", "runId") |
-
-## Relations
-
-```mermaid
-erDiagram
-
-"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
-
-"public.instance_ai_run_snapshots" {
- timestamp_3__with_time_zone createdAt
- varchar_36_ langsmithRunId
- varchar_36_ langsmithTraceId
- varchar_36_ messageGroupId
- varchar_36_ runId
- json runIds
- varchar_64_ spanId
- uuid threadId FK
- varchar_64_ traceId
- text tree
- timestamp_3__with_time_zone updatedAt
-}
-"public.instance_ai_threads" {
- timestamp_3__with_time_zone createdAt
- uuid id
- json metadata
- varchar_36_ projectId FK
- varchar_255_ resourceId
- text title
- timestamp_3__with_time_zone updatedAt
-}
-```
-
----
-
-> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/postgres-schema/public.instance_ai_threads.md b/docs/generated/postgres-schema/public.instance_ai_threads.md
index 0aeab03baa0..34ab3098bc8 100644
--- a/docs/generated/postgres-schema/public.instance_ai_threads.md
+++ b/docs/generated/postgres-schema/public.instance_ai_threads.md
@@ -5,7 +5,7 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
-| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_events](public.instance_ai_events.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
+| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_events](public.instance_ai_events.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
| metadata | json | | true | | | |
| projectId | varchar(36) | | false | | [public.project](public.project.md) | Project this thread is scoped to |
| resourceId | varchar(255) | | false | | | |
@@ -48,7 +48,6 @@ erDiagram
"public.instance_ai_observational_memory" }o--o| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE SET NULL"
"public.instance_ai_observations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;observationScopeId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
-"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
@@ -180,19 +179,6 @@ erDiagram
timestamp_3__with_time_zone updatedAt
uuid userId FK
}
-"public.instance_ai_run_snapshots" {
- timestamp_3__with_time_zone createdAt
- varchar_36_ langsmithRunId
- varchar_36_ langsmithTraceId
- varchar_36_ messageGroupId
- varchar_36_ runId
- json runIds
- varchar_64_ spanId
- uuid threadId FK
- varchar_64_ traceId
- text tree
- timestamp_3__with_time_zone updatedAt
-}
"public.instance_ai_thread_grants" {
timestamp_3__with_time_zone createdAt
varchar_512_ grantKey
diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md
index 6fc01cbb979..480b9a8b0c5 100644
--- a/docs/generated/sqlite-schema/README.md
+++ b/docs/generated/sqlite-schema/README.md
@@ -83,7 +83,6 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [instance_ai_observations](instance_ai_observations.md) | 10 | | table |
| [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) | 12 | | table |
| [instance_ai_resources](instance_ai_resources.md) | 5 | | table |
-| [instance_ai_run_snapshots](instance_ai_run_snapshots.md) | 11 | | table |
| [instance_ai_thread_grants](instance_ai_thread_grants.md) | 5 | | table |
| [instance_ai_threads](instance_ai_threads.md) | 7 | | table |
| [instance_ai_workflow_snapshots](instance_ai_workflow_snapshots.md) | 7 | | table |
@@ -262,7 +261,6 @@ erDiagram
"instance_ai_pending_confirmations" }o--o| "instance_ai_checkpoints" : "FOREIGN KEY (checkpointKey) REFERENCES instance_ai_checkpoints (key) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
-"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -1126,19 +1124,6 @@ erDiagram
datetime_3_ updatedAt
TEXT workingMemory
}
-"instance_ai_run_snapshots" {
- datetime_3_ createdAt
- varchar_36_ langsmithRunId
- varchar_36_ langsmithTraceId
- varchar_36_ messageGroupId
- varchar_36_ runId PK
- TEXT runIds
- varchar_64_ spanId
- varchar threadId PK
- varchar_64_ traceId
- TEXT tree
- datetime_3_ updatedAt
-}
"instance_ai_thread_grants" {
datetime_3_ createdAt
varchar_512_ grantKey PK
diff --git a/docs/generated/sqlite-schema/instance_ai_run_snapshots.md b/docs/generated/sqlite-schema/instance_ai_run_snapshots.md
deleted file mode 100644
index b0296d14fa2..00000000000
--- a/docs/generated/sqlite-schema/instance_ai_run_snapshots.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# instance_ai_run_snapshots
-
-## Description
-
-
-Table Definition
-
-```sql
-CREATE TABLE "instance_ai_run_snapshots" ("threadId" varchar NOT NULL, "runId" varchar(36) NOT NULL, "messageGroupId" varchar(36), "runIds" text, "tree" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "langsmithRunId" varchar(36), "langsmithTraceId" varchar(36), "traceId" varchar(64), "spanId" varchar(64), CONSTRAINT "FK_2f63fa21d09d7918f347ddbdf70" FOREIGN KEY ("threadId") REFERENCES "instance_ai_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("threadId", "runId"))
-```
-
-
-
-## Columns
-
-| Name | Type | Default | Nullable | Children | Parents | Comment |
-| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
-| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
-| langsmithRunId | varchar(36) | | true | | | |
-| langsmithTraceId | varchar(36) | | true | | | |
-| messageGroupId | varchar(36) | | true | | | |
-| runId | varchar(36) | | false | | | |
-| runIds | TEXT | | true | | | |
-| spanId | varchar(64) | | true | | | |
-| threadId | varchar | | false | | [instance_ai_threads](instance_ai_threads.md) | |
-| traceId | varchar(64) | | true | | | |
-| tree | TEXT | | false | | | |
-| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
-
-## Constraints
-
-| Name | Type | Definition |
-| ---- | ---- | ---------- |
-| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
-| runId | PRIMARY KEY | PRIMARY KEY (runId) |
-| sqlite_autoindex_instance_ai_run_snapshots_1 | PRIMARY KEY | PRIMARY KEY (threadId, runId) |
-| threadId | PRIMARY KEY | PRIMARY KEY (threadId) |
-
-## Indexes
-
-| Name | Definition |
-| ---- | ---------- |
-| IDX_d3a2bc880e7a8626802e5474ad | CREATE INDEX "IDX_d3a2bc880e7a8626802e5474ad" ON "instance_ai_run_snapshots" ("threadId", "createdAt") |
-| IDX_d926c16c2ad9728cb9a81790c0 | CREATE INDEX "IDX_d926c16c2ad9728cb9a81790c0" ON "instance_ai_run_snapshots" ("threadId", "messageGroupId") |
-| sqlite_autoindex_instance_ai_run_snapshots_1 | PRIMARY KEY (threadId, runId) |
-
-## Relations
-
-```mermaid
-erDiagram
-
-"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
-
-"instance_ai_run_snapshots" {
- datetime_3_ createdAt
- varchar_36_ langsmithRunId
- varchar_36_ langsmithTraceId
- varchar_36_ messageGroupId
- varchar_36_ runId PK
- TEXT runIds
- varchar_64_ spanId
- varchar threadId PK
- varchar_64_ traceId
- TEXT tree
- datetime_3_ updatedAt
-}
-"instance_ai_threads" {
- datetime_3_ createdAt
- varchar id PK
- TEXT metadata
- varchar_36_ projectId FK
- varchar_255_ resourceId
- TEXT title
- datetime_3_ updatedAt
-}
-```
-
----
-
-> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/sqlite-schema/instance_ai_threads.md b/docs/generated/sqlite-schema/instance_ai_threads.md
index 6bc2f82bcf2..272f2ffdf2f 100644
--- a/docs/generated/sqlite-schema/instance_ai_threads.md
+++ b/docs/generated/sqlite-schema/instance_ai_threads.md
@@ -16,7 +16,7 @@ CREATE TABLE "instance_ai_threads" ("id" varchar PRIMARY KEY NOT NULL, "resource
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
-| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_events](instance_ai_events.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_run_snapshots](instance_ai_run_snapshots.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
+| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_events](instance_ai_events.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
| metadata | TEXT | | true | | | |
| projectId | varchar(36) | | false | | [project](project.md) | |
| resourceId | varchar(255) | | false | | | |
@@ -54,7 +54,6 @@ erDiagram
"instance_ai_observational_memory" }o--o| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"instance_ai_observations" }o--|| "instance_ai_threads" : "FOREIGN KEY (observationScopeId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
-"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -186,19 +185,6 @@ erDiagram
datetime_3_ updatedAt
varchar userId FK
}
-"instance_ai_run_snapshots" {
- datetime_3_ createdAt
- varchar_36_ langsmithRunId
- varchar_36_ langsmithTraceId
- varchar_36_ messageGroupId
- varchar_36_ runId PK
- TEXT runIds
- varchar_64_ spanId
- varchar threadId PK
- varchar_64_ traceId
- TEXT tree
- datetime_3_ updatedAt
-}
"instance_ai_thread_grants" {
datetime_3_ createdAt
varchar_512_ grantKey PK
diff --git a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts
index 7945d39ca51..fe4fb811234 100644
--- a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts
+++ b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts
@@ -1,7 +1,7 @@
/**
* Shared event reducer for Instance AI agent runs.
*
- * Used by both the frontend (live SSE updates) and the backend (snapshot building).
+ * Used by both the frontend (live SSE updates) and the backend (history folds, run-sync bootstrap).
* All state is plain objects/arrays — no Map/Set — so it's Pinia-safe and easy
* to inspect in tests.
*
@@ -457,7 +457,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
agent.result = event.payload.result;
agent.error = event.payload.error;
// A completed/errored agent can't have tool calls still in-flight.
- // Clear isLoading so persisted snapshots don't show stale confirmations.
+ // Clear isLoading so folded history trees don't show stale confirmations.
for (const tc of agent.toolCalls) {
if (tc.isLoading) {
tc.isLoading = false;
@@ -547,7 +547,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
}
}
// A terminated run can't have tool calls still in-flight.
- // Clear isLoading so persisted snapshots don't show stale confirmations.
+ // Clear isLoading so folded history trees don't show stale confirmations.
if (state.status === 'cancelled' || state.status === 'error') {
for (const tc of Object.values(state.toolCallsById)) {
if (tc.isLoading) {
diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts
index ce73119976d..e684c617137 100644
--- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts
+++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts
@@ -292,6 +292,14 @@ export const runStartPayloadSchema = z.object({
.describe(
'Stable ID for the assistant message group that owns this run. Used to reconnect live activity back to the correct assistant bubble.',
),
+ langsmithRunId: z
+ .string()
+ .optional()
+ .describe('LangSmith root-run ID, so user feedback can annotate the trace after a restart.'),
+ langsmithTraceId: z
+ .string()
+ .optional()
+ .describe('LangSmith trace ID paired with langsmithRunId for feedback annotation.'),
});
export const runFinishPayloadSchema = z.object({
@@ -1041,8 +1049,8 @@ const eventBase = {
userId: z.string().optional(),
/** Anthropic API response ID (msg_01...) — groups events from the same LLM response. */
responseId: z.string().optional(),
- /** Epoch ms stamped once at publish — replays (SSE reconnect, snapshot
- * rebuilds) use it to reconstruct real timing instead of "now". */
+ /** Epoch ms stamped once at publish — replays (SSE reconnect, history
+ * folds) use it to reconstruct real timing instead of "now". */
ts: z.number().optional(),
};
diff --git a/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts b/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts
index 1b910706cdc..3e8ca05e965 100644
--- a/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts
+++ b/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts
@@ -38,9 +38,9 @@ import type { IrreversibleMigration, MigrationContext } from '../migration-types
* reproduced, and re-emitting request facts would resurrect dead approval
* prompts), per-call timing (synthesized facts share the snapshot timestamp,
* so durations render as instant — the tree stores no segment timing to do
- * better), and langsmith feedback anchors (they live in snapshot COLUMNS,
- * which outlive Gate B until the table drops, so feedback on pre-log threads
- * keeps resolving unchanged; the Gate B anchor relocation carries them over).
+ * better), and langsmith feedback anchors (they live in snapshot COLUMNS and
+ * drop with the table in Gate B; feedback on those turns is still stored, it
+ * just no longer annotates the LangSmith trace).
*/
// ---------------------------------------------------------------------------
diff --git a/packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts b/packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts
new file mode 100644
index 00000000000..e1fb61879fe
--- /dev/null
+++ b/packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts
@@ -0,0 +1,44 @@
+import type { MigrationContext, ReversibleMigration } from '../migration-types';
+
+const table = 'instance_ai_run_snapshots';
+
+/**
+ * The agent-tree snapshot store is retired: history and the SSE bootstrap
+ * derive their trees by folding `instance_ai_events`, so the persisted tree is
+ * no longer read or written. The LangSmith feedback anchor that used to live
+ * here rides on the durable log's `run-start` fact from this release on; runs
+ * recorded before that resolve no anchor, so feedback on those turns is still
+ * stored but no longer annotates the LangSmith trace — accepted, to keep this
+ * a plain drop.
+ *
+ * The `down` recreates the table at its final schema (base columns plus the
+ * trace/LangSmith ids added by later migrations) so a rollback restores a
+ * structurally-identical table — the historical rows are not recoverable.
+ */
+export class DropInstanceAiRunSnapshotsTable1788336311704 implements ReversibleMigration {
+ async up({ schemaBuilder }: MigrationContext) {
+ await schemaBuilder.dropTable(table);
+ }
+
+ async down({ schemaBuilder: { createTable, column } }: MigrationContext) {
+ await createTable(table)
+ .withColumns(
+ column('threadId').uuid.primary.notNull,
+ column('runId').varchar(36).primary.notNull,
+ column('messageGroupId').varchar(36),
+ column('runIds').json,
+ column('tree').text.notNull,
+ column('traceId').varchar(64),
+ column('spanId').varchar(64),
+ column('langsmithRunId').varchar(36),
+ column('langsmithTraceId').varchar(36),
+ )
+ .withIndexOn(['threadId', 'messageGroupId'])
+ .withIndexOn(['threadId', 'createdAt'])
+ .withForeignKey('threadId', {
+ tableName: 'instance_ai_threads',
+ columnName: 'id',
+ onDelete: 'CASCADE',
+ }).withTimestamps;
+ }
+}
diff --git a/packages/@n8n/instance-ai/docs/architecture.md b/packages/@n8n/instance-ai/docs/architecture.md
index a378cb4b5b5..82370e884d9 100644
--- a/packages/@n8n/instance-ai/docs/architecture.md
+++ b/packages/@n8n/instance-ai/docs/architecture.md
@@ -194,7 +194,7 @@ The agent package — framework-agnostic business logic.
- **Workflow builder** (`workflow-builder/`) — TypeScript SDK source files, parsing, validation, and prompt sections
- **Workspace** (`workspace/`) — sandbox provisioning (n8n sandbox service / Daytona), filesystem abstraction, snapshot management
- **Memory** (`memory/`) — title generation, memory configuration
-- **Storage** (`storage/`) — iteration logs, task storage, planned task storage, workflow loop storage, agent tree snapshots
+- **Storage** (`storage/`) — iteration logs, task storage, planned task storage, workflow loop storage
- **MCP client** (`mcp/`) — manages connections to external MCP servers, schema sanitization for Anthropic compatibility
- **Domain access** (`domain-access/`) — domain gating and access tracking for external URL approval
- **Stream mapping** (`stream/`) — agent chunk → canonical event translation, HITL consumption
@@ -222,9 +222,9 @@ The n8n integration layer.
replay belong to the durable event log, not the bus
- **Filesystem** — `LocalGateway` (remote daemon via SSE protocol).
See `docs/filesystem-access.md`
-- **Persistence** — 13 TypeORM entity/repository pairs for threads, messages,
- resources, observations, observation cursors and locks, checkpoints, run
- snapshots, event-log entries, pending confirmations, iteration logs, thread
+- **Persistence** — 12 TypeORM entity/repository pairs for threads, messages,
+ resources, observations, observation cursors and locks, checkpoints,
+ event-log entries, pending confirmations, iteration logs, thread
grants, and MCP registry connections
### `packages/@n8n/api-types` (Shared Types)
diff --git a/packages/@n8n/instance-ai/docs/configuration.md b/packages/@n8n/instance-ai/docs/configuration.md
index 0ee2e449808..23b89f77456 100644
--- a/packages/@n8n/instance-ai/docs/configuration.md
+++ b/packages/@n8n/instance-ai/docs/configuration.md
@@ -211,7 +211,7 @@ The same storage backend is used for:
- Message history
- Observational memory (observation log, cursors, and task locks)
- Plan storage (thread-scoped in thread metadata)
-- Run snapshots and checkpoints (separate tables)
+- Checkpoints (separate table)
## Event Bus
diff --git a/packages/@n8n/instance-ai/docs/memory.md b/packages/@n8n/instance-ai/docs/memory.md
index ace567cec89..857c7cb7fa7 100644
--- a/packages/@n8n/instance-ai/docs/memory.md
+++ b/packages/@n8n/instance-ai/docs/memory.md
@@ -26,8 +26,8 @@ SQLite instance n8n already uses, selected automatically from n8n's own database
configuration.
That backend holds message history, observational memory (observation log,
-cursors and task locks), plan state in thread metadata, and run snapshots and
-checkpoints in their own tables.
+cursors and task locks), plan state in thread metadata, and checkpoints in
+their own table.
### Tier 2: Observational Memory
diff --git a/packages/@n8n/instance-ai/docs/streaming-protocol.md b/packages/@n8n/instance-ai/docs/streaming-protocol.md
index 42e222ef82d..9127279e0f5 100644
--- a/packages/@n8n/instance-ai/docs/streaming-protocol.md
+++ b/packages/@n8n/instance-ai/docs/streaming-protocol.md
@@ -589,16 +589,16 @@ replaying all SSE events.
1. **Persisted messages** — `@n8n/agents` persists tool invocations, reasoning, and
text in its message format. The backend parses these into rich
- `InstanceAiMessage[]` objects with tool calls and flat agent trees.
+ `InstanceAiMessage[]` objects.
-2. **Agent trees** — with the durable log enabled, history folds event-log rows
- through `buildAgentTreeFromEvents()` when it reads a page. Stored snapshots
- remain as the non-durable path and as a fallback for older history. The
- backend updates snapshots when runs and background tasks settle.
+2. **Agent trees** — history folds event-log rows through
+ `buildAgentTreeFromEvents()` when it reads a page. The log is the only tree
+ source: a message whose run left no log rows renders from its own
+ text/reasoning content without a tree.
3. **SSE cursor** — the messages response includes `nextEventId`. The frontend
sets its SSE cursor to `nextEventId - 1` so the SSE connection only receives
- events that arrived after the historical snapshot. This prevents duplicate
+ events that arrived after the historical messages. This prevents duplicate
messages on refresh.
### Frontend Flow
diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts
index 630bb4fb6bb..420ca3afb17 100644
--- a/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts
+++ b/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts
@@ -419,7 +419,7 @@ describe('executeResumableStream', () => {
});
const publishedEvents = eventBus.publish.mock.calls.map(([, event]) => event as PublishedEvent);
- // The output redactor may merge contiguous deltas, so match by prefix.
+ // Match the segment's first delta by prefix; deltas publish per chunk.
const first = publishedEvents.find((event) => event.payload?.text?.startsWith('First'));
const toolCall = publishedEvents.find((event) => event.type === 'tool-call');
const second = publishedEvents.find((event) => event.payload?.text === 'Second segment');
@@ -461,7 +461,7 @@ describe('executeResumableStream', () => {
});
const publishedEvents = eventBus.publish.mock.calls.map(([, event]) => event as PublishedEvent);
- // The output redactor may merge contiguous deltas, so match by prefix.
+ // Match each segment's delta by prefix.
const first = publishedEvents.find((event) => event.payload?.text?.startsWith('First'));
const second = publishedEvents.find((event) => event.payload?.text?.startsWith('Second'));
diff --git a/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts b/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts
index e0802a76978..cfcb3b74169 100644
--- a/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts
+++ b/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts
@@ -1,5 +1,5 @@
import { isFinishReason } from '@n8n/agents';
-import type { FinishReason, RedactionOptions, StreamResult } from '@n8n/agents';
+import type { FinishReason, StreamResult } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { randomUUID } from 'node:crypto';
@@ -11,7 +11,6 @@ import type {
OrchestratorRunStopSignal,
} from './orchestrator-run-control';
import { isQuotaExhaustedError, mapAgentChunkToEvent } from '../stream/map-chunk';
-import { OutputRedactor } from '../stream/output-redaction';
import { UsageAccumulator, type RunTokenUsage } from '../stream/usage-accumulator';
import { WorkSummaryAccumulator, type WorkSummary } from '../stream/work-summary-accumulator';
import { parseSuspension, resumeAgentStream } from '../utils/stream-helpers';
@@ -41,12 +40,6 @@ export interface ResumableStreamContext {
onActivity?: () => void;
/** Stop consuming after the current chunk has been mapped and published. */
stopSignal?: () => OrchestratorRunStopSignal | undefined;
- /**
- * Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
- * default policy, which on the durable-log path would persist redacted text
- * — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
- */
- outputRedaction?: RedactionOptions | false;
}
export interface ManualSuspensionControl {
@@ -254,10 +247,10 @@ function recordSuspension(
}
/**
- * Publish redacted events, holding back the primary confirmation-request event in
+ * Publish events, holding back the primary confirmation-request event in
* manual mode and de-duplicating it. Returns the updated confirmation-tracking state.
*/
-function publishRedactedEvents(
+function publishEvents(
events: InstanceAiEvent[],
args: {
suspension: SuspensionInfo | undefined;
@@ -318,7 +311,7 @@ interface StreamPassResult {
}
/**
- * Consume one stream until it ends (or is cancelled), publishing redacted events,
+ * Consume one stream until it ends (or is cancelled), publishing events,
* accumulating usage/work, and capturing the first suspension. Returns the pass
* outcome plus the updated response-id / step counters for the next pass.
*/
@@ -328,18 +321,11 @@ async function consumeStreamPass(args: {
options: ExecuteResumableStreamOptions;
workSummaryAccumulator: WorkSummaryAccumulator;
usageAccumulator: UsageAccumulator;
- outputRedactor: OutputRedactor;
currentResponseId: string | undefined;
nativeStepIndex: number;
}): Promise {
- const {
- activeStream,
- activeAgentRunId,
- options,
- workSummaryAccumulator,
- usageAccumulator,
- outputRedactor,
- } = args;
+ const { activeStream, activeAgentRunId, options, workSummaryAccumulator, usageAccumulator } =
+ args;
let currentResponseId = args.currentResponseId;
let nativeStepIndex = args.nativeStepIndex;
/**
@@ -448,12 +434,9 @@ async function consumeStreamPass(args: {
const isFinishStep = isRecord(chunk) && chunk.type === 'finish-step';
if ((mappedEvent && !isDeltaChunk) || isFinishStep) syntheticSegmentId = undefined;
- // Scan/redact secrets & PII before events reach the user. Buffered
- // delta text is released here at structural boundaries, so this may
- // expand into several events (or none, while text is held back).
- const events = mappedEvent ? outputRedactor.processEvent(mappedEvent) : [];
+ const events = mappedEvent ? [mappedEvent] : [];
- const published = publishRedactedEvents(events, {
+ const published = publishEvents(events, {
suspension,
confirmationEvent,
confirmationEventPublished,
@@ -510,13 +493,6 @@ export async function executeResumableStream(
let text = options.stream.text;
const workSummaryAccumulator = new WorkSummaryAccumulator();
const usageAccumulator = new UsageAccumulator();
- const outputRedactor = new OutputRedactor({
- logger: options.context.logger,
- threadId: options.context.threadId,
- runId: options.context.runId,
- agentId: options.context.agentId,
- options: options.context.outputRedaction,
- });
let currentResponseId: string | undefined;
let nativeStepIndex = 0;
@@ -528,7 +504,6 @@ export async function executeResumableStream(
options,
workSummaryAccumulator,
usageAccumulator,
- outputRedactor,
currentResponseId,
nativeStepIndex,
});
@@ -542,11 +517,6 @@ export async function executeResumableStream(
const { suspension, hasError, error, pendingConfirmation, confirmationEvent } = pass;
const { drainedCorrectionsForResume } = pass;
- for (const flushed of outputRedactor.flush()) {
- workSummaryAccumulator.observe(flushed);
- options.context.eventBus.publish(options.context.threadId, flushed);
- }
-
if (options.context.signal.aborted) {
return buildCancelledResult(activeAgentRunId, text, workSummaryAccumulator, usageAccumulator);
}
diff --git a/packages/@n8n/instance-ai/src/runtime/stream-runner.ts b/packages/@n8n/instance-ai/src/runtime/stream-runner.ts
index 69fb6977992..b80f693e204 100644
--- a/packages/@n8n/instance-ai/src/runtime/stream-runner.ts
+++ b/packages/@n8n/instance-ai/src/runtime/stream-runner.ts
@@ -1,4 +1,3 @@
-import type { RedactionOptions } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiEventBus } from '../event-bus';
@@ -31,12 +30,6 @@ export interface StreamRunOptions {
logger: Logger;
onActivity?: () => void;
stopSignal?: () => OrchestratorRunStopSignal | undefined;
- /**
- * Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
- * default policy, which on the durable-log path would persist redacted text
- * — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
- */
- outputRedaction?: RedactionOptions | false;
}
export interface StreamRunResult {
@@ -92,7 +85,6 @@ async function consumeStream(
logger: options.logger,
onActivity: options.onActivity,
stopSignal: options.stopSignal,
- outputRedaction: options.outputRedaction,
},
control: { mode: 'manual' },
initialAgentRunId: options.agentRunId,
diff --git a/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts b/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts
index 2043f96d4be..dc0ff10ef04 100644
--- a/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts
+++ b/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts
@@ -5,10 +5,6 @@ export interface AgentTreeSnapshot {
runId: string;
messageGroupId?: string;
runIds?: string[];
- traceId?: string;
- spanId?: string;
- langsmithRunId?: string;
- langsmithTraceId?: string;
createdAt?: Date;
updatedAt?: Date;
}
diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts
deleted file mode 100644
index 326e0a15e5d..00000000000
--- a/packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts
+++ /dev/null
@@ -1,356 +0,0 @@
-import type { RedactionOptions } from '@n8n/agents';
-import type { InstanceAiEvent } from '@n8n/api-types';
-
-import type { Logger } from '../../logger';
-import { OutputRedactor } from '../output-redaction';
-
-function createLogger() {
- return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger;
-}
-
-function createRedactor(logger = createLogger(), options?: RedactionOptions | false) {
- return new OutputRedactor({
- logger,
- threadId: 'thread-1',
- runId: 'run-1',
- agentId: 'agent-1',
- ...(options !== undefined ? { options } : {}),
- });
-}
-
-function textDelta(text: string): InstanceAiEvent {
- return { type: 'text-delta', runId: 'run-1', agentId: 'agent-1', payload: { text } };
-}
-
-function reasoningDelta(text: string, responseId?: string): InstanceAiEvent {
- return {
- type: 'reasoning-delta',
- runId: 'run-1',
- agentId: 'agent-1',
- ...(responseId ? { responseId } : {}),
- payload: { text },
- };
-}
-
-function collectText(events: InstanceAiEvent[]): string {
- return events.map((e) => ('payload' in e && 'text' in e.payload ? e.payload.text : '')).join('');
-}
-
-function collectTextOfType(
- events: InstanceAiEvent[],
- type: 'text-delta' | 'reasoning-delta',
-): string {
- return collectText(events.filter((e) => e.type === type));
-}
-
-describe('OutputRedactor', () => {
- it('redacts a secret split across two text deltas', () => {
- const redactor = createRedactor();
- const emitted = [
- ...redactor.processEvent(textDelta('your key is sk-ant-')),
- ...redactor.processEvent(textDelta('api03-aaaaaaaaaaaaaaaa, keep it safe')),
- ...redactor.flush(),
- ];
- const out = collectText(emitted);
- expect(out).toBe('your key is [REDACTED], keep it safe');
- expect(out).not.toContain('sk-ant-');
- });
-
- it('redacts a PII email when the email category is enabled', () => {
- const redactor = createRedactor(createLogger(), { detect: ['email'] });
- const emitted = [
- ...redactor.processEvent(textDelta('email me at jane@example.com ok')),
- ...redactor.flush(),
- ];
- expect(collectText(emitted)).toBe('email me at [REDACTED] ok');
- });
-
- it('redacts credit cards by default but leaves email untouched', () => {
- const redactor = createRedactor();
- const emitted = [
- ...redactor.processEvent(textDelta('card 4111 1111 1111 1111 mail jane@example.com')),
- ...redactor.flush(),
- ];
- const out = collectText(emitted);
- expect(out).toBe('card [REDACTED] mail jane@example.com');
- });
-
- it('passes non-sensitive prose through unchanged', () => {
- const redactor = createRedactor();
- const text = 'The workflow completed and produced three items successfully.';
- const emitted = [...redactor.processEvent(textDelta(text)), ...redactor.flush()];
- expect(collectText(emitted)).toBe(text);
- });
-
- describe('channel switches', () => {
- const reasoning =
- 'I should check the FAQ sheet first and then decide whether a ticket is needed.';
- const text = 'Let me start by loading the builder guidance and reviewing the knowledge base.';
-
- it('releases the reasoning tail before text that follows it', () => {
- const redactor = createRedactor();
- const emitted = [
- ...redactor.processEvent(reasoningDelta(reasoning)),
- ...redactor.processEvent(textDelta(text)),
- ...redactor.flush(),
- ];
-
- // All reasoning must be published before the first text delta —
- // otherwise the held-back reasoning tail renders as a stray
- // mid-sentence reasoning block in the UI.
- const types = emitted.map((e) => e.type);
- expect(types.lastIndexOf('reasoning-delta')).toBeLessThan(types.indexOf('text-delta'));
- // No content is lost or reordered within a channel.
- expect(collectTextOfType(emitted, 'reasoning-delta')).toBe(reasoning);
- expect(collectTextOfType(emitted, 'text-delta')).toBe(text);
- });
-
- it('releases the text tail before reasoning that follows it', () => {
- const redactor = createRedactor();
- const emitted = [
- ...redactor.processEvent(textDelta(text)),
- ...redactor.processEvent(reasoningDelta(reasoning)),
- ...redactor.flush(),
- ];
-
- const types = emitted.map((e) => e.type);
- expect(types.lastIndexOf('text-delta')).toBeLessThan(types.indexOf('reasoning-delta'));
- expect(collectTextOfType(emitted, 'text-delta')).toBe(text);
- expect(collectTextOfType(emitted, 'reasoning-delta')).toBe(reasoning);
- });
-
- it('drains the tail under its original responseId', () => {
- const redactor = createRedactor();
- const emitted = [
- ...redactor.processEvent(reasoningDelta(reasoning, 'run-1:step:1')),
- ...redactor.processEvent(textDelta(text)),
- ...redactor.flush(),
- ];
-
- const reasoningEvents = emitted.filter((e) => e.type === 'reasoning-delta');
- expect(reasoningEvents.length).toBeGreaterThan(1);
- for (const event of reasoningEvents) {
- expect(event).toMatchObject({ responseId: 'run-1:step:1' });
- }
- });
- });
-
- it('preserves responseId on flushed delta text', () => {
- const redactor = createRedactor();
- const emitted = [
- ...redactor.processEvent({
- type: 'text-delta',
- runId: 'run-1',
- agentId: 'agent-1',
- responseId: 'run-1:step:1',
- payload: { text: 'hello there' },
- }),
- ...redactor.flush(),
- ];
- expect(emitted).toHaveLength(1);
- expect(emitted[0]).toMatchObject({ type: 'text-delta', responseId: 'run-1:step:1' });
- });
-
- it('redacts secrets nested inside tool-call args', () => {
- const redactor = createRedactor();
- const event: InstanceAiEvent = {
- type: 'tool-call',
- runId: 'run-1',
- agentId: 'agent-1',
- payload: {
- toolCallId: 'tc-1',
- toolName: 'http_request',
- args: { headers: { authorization: 'Bearer abcdef1234567890' } },
- },
- };
- const [out] = redactor.processEvent(event);
- expect(JSON.stringify(out)).toContain('[REDACTED]');
- expect(JSON.stringify(out)).not.toContain('abcdef1234567890');
- // Identifier fields are preserved.
- expect(out).toMatchObject({ payload: { toolCallId: 'tc-1', toolName: 'http_request' } });
- });
-
- it('redacts secrets nested inside a tool-result', () => {
- const redactor = createRedactor();
- const event: InstanceAiEvent = {
- type: 'tool-result',
- runId: 'run-1',
- agentId: 'agent-1',
- payload: {
- toolCallId: 'tc-1',
- result: { headers: { authorization: 'Bearer abcdef1234567890' } },
- },
- };
- const [out] = redactor.processEvent(event);
- expect(JSON.stringify(out)).toContain('[REDACTED]');
- expect(JSON.stringify(out)).not.toContain('abcdef1234567890');
- });
-
- it('leaves upstream browser redaction markers intact in a tool-result', () => {
- const redactor = createRedactor();
- const event: InstanceAiEvent = {
- type: 'tool-result',
- runId: 'run-1',
- agentId: 'agent-1',
- payload: {
- toolCallId: 'tc-1',
- result: {
- snapshot: 'Client Secret [REDACTED:secret:1] Signing Secret [REDACTED:secret:2]',
- },
- },
- };
- const [out] = redactor.processEvent(event);
- const serialized = JSON.stringify(out);
- expect(serialized).not.toContain('[REDACTED:[REDACTED');
- expect(serialized).toContain('[REDACTED:secret:1]');
- expect(serialized).toContain('[REDACTED:secret:2]');
- });
-
- it('redacts confirmation card display text', () => {
- const redactor = createRedactor(createLogger(), { detect: ['email'] });
- const event: InstanceAiEvent = {
- type: 'confirmation-request',
- runId: 'run-1',
- agentId: 'agent-1',
- payload: {
- requestId: 'req-1',
- toolCallId: 'tc-1',
- toolName: 'ask',
- args: {},
- severity: 'warning',
- message: 'What to do with jane@example.com?',
- targetApproval: {
- toolName: 'send_jane@example.com',
- displayName: 'Email jane@example.com',
- args: { recipient: 'jane@example.com' },
- },
- introMessage: 'I noticed jane@example.com in your request.',
- questions: [
- {
- id: 'q1',
- question: 'Where should jane@example.com be used?',
- type: 'single',
- options: ['Reply-to jane@example.com', 'Skip'],
- },
- ],
- tasks: { tasks: [{ id: 't1', description: 'Email jane@example.com', status: 'todo' }] },
- planItems: [
- {
- id: 'p1',
- title: 'Notify jane@example.com',
- kind: 'task',
- spec: 'Send to jane@example.com',
- deps: [],
- },
- ],
- },
- };
-
- const [out] = redactor.processEvent(event);
- const serialized = JSON.stringify(out);
- expect(serialized).not.toContain('jane@example.com');
- expect(serialized).toContain('[REDACTED]');
- // Control/identifier fields are preserved so suspend/resume keeps working.
- expect(out).toMatchObject({
- payload: {
- requestId: 'req-1',
- toolCallId: 'tc-1',
- targetApproval: {
- toolName: '[REDACTED]',
- displayName: 'Email [REDACTED]',
- args: { recipient: '[REDACTED]' },
- },
- },
- });
- expect(event.payload.targetApproval?.args).toEqual({ recipient: 'jane@example.com' });
- });
-
- it('withholds target approval args nested beyond the redaction depth limit', () => {
- const redactor = createRedactor();
- const secret = 'Bearer abcdef1234567890';
- let nested: unknown = { secret };
- for (let depth = 0; depth < 9; depth++) nested = { nested };
- const event: InstanceAiEvent = {
- type: 'confirmation-request',
- runId: 'run-1',
- agentId: 'agent-1',
- payload: {
- requestId: 'req-1',
- toolCallId: 'tc-1',
- toolName: 'call_agent',
- args: {},
- severity: 'warning',
- message: 'Confirm action',
- targetApproval: {
- toolName: 'deep_action',
- args: { visible: 'ordinary value', nested },
- },
- },
- };
-
- const [out] = redactor.processEvent(event);
- if (out.type !== 'confirmation-request') throw new Error('Expected confirmation request');
- const serializedArgs = JSON.stringify(out.payload.targetApproval?.args);
- expect(serializedArgs).not.toContain(secret);
- expect(serializedArgs).toContain('[REDACTED]');
- expect(out.payload.targetApproval?.args).toMatchObject({ visible: 'ordinary value' });
- });
-
- it('logs a filtering summary with category counts and no values', () => {
- const logger = createLogger();
- const redactor = createRedactor(logger, { secrets: true, detect: ['email'] });
- redactor.processEvent(
- textDelta('key sk-ant-api03-aaaaaaaaaaaaaaaa and mail jane@example.com here'),
- );
- redactor.flush();
-
- expect(logger.info).toHaveBeenCalledWith(
- 'Instance AI redacted sensitive content from agent output',
- expect.objectContaining({
- threadId: 'thread-1',
- runId: 'run-1',
- agentId: 'agent-1',
- count: 2,
- categories: { secret: 1, email: 1 },
- }),
- );
- const meta = vi.mocked(logger.info).mock.calls[0][1];
- expect(JSON.stringify(meta)).not.toContain('sk-ant-');
- expect(JSON.stringify(meta)).not.toContain('jane@example.com');
- });
-
- it('does not log when nothing is redacted', () => {
- const logger = createLogger();
- const redactor = createRedactor(logger);
- redactor.processEvent(textDelta('all clear, nothing sensitive here at all'));
- redactor.flush();
- expect(logger.info).not.toHaveBeenCalled();
- });
-
- describe('when disabled (options: false)', () => {
- function createDisabled(logger = createLogger()) {
- return new OutputRedactor({
- logger,
- threadId: 'thread-1',
- runId: 'run-1',
- agentId: 'agent-1',
- options: false,
- });
- }
-
- it('passes events through untouched, including secrets', () => {
- const redactor = createDisabled();
- const input = textDelta('your key is sk-ant-api03-aaaaaaaaaaaaaaaa here');
- const emitted = [...redactor.processEvent(input), ...redactor.flush()];
- expect(emitted).toEqual([input]);
- });
-
- it('does not log a filtering summary', () => {
- const logger = createLogger();
- const redactor = createDisabled(logger);
- redactor.processEvent(textDelta('key sk-ant-api03-aaaaaaaaaaaaaaaa'));
- redactor.flush();
- expect(logger.info).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts b/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts
index 6c02322dbb5..ec0beb16fef 100644
--- a/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts
+++ b/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts
@@ -1,4 +1,3 @@
-import type { RedactionOptions } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiEventBus } from '../event-bus/event-bus.interface';
@@ -35,12 +34,6 @@ export interface ConsumeWithHitlOptions {
resumeOptions?: Record;
/** Native agent persistence owner for suspended sub-agent state. */
persistence?: { threadId: string; resourceId: string };
- /**
- * Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
- * default policy, which on the durable-log path would persist redacted text
- * — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
- */
- outputRedaction?: RedactionOptions | false;
}
export interface ConsumeWithHitlResult {
@@ -97,7 +90,6 @@ export async function consumeStreamWithHitl(
eventBus: options.eventBus,
signal: options.abortSignal,
logger: options.logger,
- outputRedaction: options.outputRedaction,
},
control: {
mode: 'auto',
@@ -131,12 +123,6 @@ export interface ConsumeStreamCascadingOptions {
logger: Logger;
threadId: string;
abortSignal: AbortSignal;
- /**
- * Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
- * default policy, which on the durable-log path would persist redacted text
- * — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
- */
- outputRedaction?: RedactionOptions | false;
}
export type ConsumeStreamCascadingResult =
@@ -185,7 +171,6 @@ export async function consumeStreamCascading(
eventBus: options.eventBus,
signal: options.abortSignal,
logger: options.logger,
- outputRedaction: options.outputRedaction,
},
control: { mode: 'manual' },
});
diff --git a/packages/@n8n/instance-ai/src/stream/output-redaction.ts b/packages/@n8n/instance-ai/src/stream/output-redaction.ts
deleted file mode 100644
index df2a4a746bf..00000000000
--- a/packages/@n8n/instance-ai/src/stream/output-redaction.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-import {
- StreamingRedactor,
- redactText,
- redactDeep,
- type RedactionCategory,
- type RedactionOptions,
-} from '@n8n/agents';
-import type { InstanceAiEvent } from '@n8n/api-types';
-import { isRecord } from '@n8n/utils/is-record';
-
-import type { Logger } from '../logger';
-
-/**
- * Default output-filtering policy for Instance AI: redact known credential/
- * secret patterns plus credit-card numbers. Other PII categories (`email`,
- * `ssn-us`) are implemented but off by default until we decide which to enable.
- *
- * Instance AI's own streams pass `false` (raw-at-rest, INS-837), so this policy
- * applies only to callers that opt in. When it does run it always redacts
- * matches; the SDK's `GuardrailStrategy` also defines `block` and `warn`, but
- * those are not implemented here.
- */
-export const DEFAULT_OUTPUT_REDACTION_OPTIONS: RedactionOptions = {
- secrets: true,
- detect: ['credit-card'],
-};
-
-interface OutputRedactorContext {
- logger: Logger;
- threadId: string;
- runId: string;
- agentId: string;
- /**
- * Redaction policy: omit for the default policy, pass options to customise,
- * or `false` to disable scanning entirely (events pass through untouched).
- * NOTE: omission means ENABLED — callers on a persistence path must pass
- * `false` explicitly or the stored text is redacted.
- */
- options?: RedactionOptions | false;
-}
-
-const MAX_TARGET_APPROVAL_ARG_DEPTH = 8;
-const WITHHELD_TARGET_APPROVAL_ARG = '[REDACTED]';
-
-function withholdDeepTargetApprovalArgs(value: unknown, depth = 0): unknown {
- if (value === null || typeof value !== 'object') return value;
- if (depth >= MAX_TARGET_APPROVAL_ARG_DEPTH) return WITHHELD_TARGET_APPROVAL_ARG;
- if (Array.isArray(value)) {
- return value.map((item) => withholdDeepTargetApprovalArgs(item, depth + 1));
- }
- return Object.fromEntries(
- Object.entries(value).map(([key, item]) => [
- key,
- withholdDeepTargetApprovalArgs(item, depth + 1),
- ]),
- );
-}
-
-type DeltaType = 'text-delta' | 'reasoning-delta';
-
-interface Channel {
- readonly type: DeltaType;
- readonly redactor: StreamingRedactor;
- responseId?: string;
-}
-
-/**
- * Scans agent output events for secrets/PII before they reach the user and
- * redacts matches in place.
- *
- * Text/reasoning deltas are streamed through a holdback-buffered redactor so a
- * secret split across chunk boundaries is caught. Buffered text is released
- * (with its original `responseId`) whenever a structural event (tool call,
- * result, …), a new step boundary, or a channel switch (reasoning → text or
- * back) arrives — secrets never span those boundaries — so cross-channel
- * event ordering and step grouping are preserved. Without the channel-switch
- * drain, a reasoning tail held back by the redactor would be published after
- * the text that followed it, rendering as a stray mid-sentence reasoning
- * block in the UI. Tool results/errors are redacted one-shot.
- *
- * One instance per run. Call {@link processEvent} on each mapped event; it
- * returns the ordered events to publish. Call {@link flush} when the active
- * stream segment ends to release any remainder and log a filtering summary.
- */
-export class OutputRedactor {
- private readonly enabled: boolean;
-
- private readonly options: RedactionOptions;
-
- private readonly text: Channel;
-
- private readonly reasoning: Channel;
-
- private matches: RedactionCategory[] = [];
-
- constructor(private readonly context: OutputRedactorContext) {
- this.enabled = context.options !== false;
- // When disabled the options are unused; default keeps the type concrete.
- this.options =
- context.options === false || context.options === undefined
- ? DEFAULT_OUTPUT_REDACTION_OPTIONS
- : context.options;
- this.text = { type: 'text-delta', redactor: new StreamingRedactor(this.options) };
- this.reasoning = { type: 'reasoning-delta', redactor: new StreamingRedactor(this.options) };
- }
-
- /** Redact an outgoing event; returns the ordered events that should be published. */
- processEvent(event: InstanceAiEvent): InstanceAiEvent[] {
- if (!this.enabled) return [event];
- if (event.type === 'text-delta') return this.processDelta(event, this.text, this.reasoning);
- if (event.type === 'reasoning-delta') {
- return this.processDelta(event, this.reasoning, this.text);
- }
-
- // Structural event: release any buffered text first so ordering is kept.
- return [
- ...this.drainChannel(this.reasoning),
- ...this.drainChannel(this.text),
- this.redactStructural(event),
- ];
- }
-
- /** Release buffered text for both channels and log a filtering summary. Call at segment end. */
- flush(): InstanceAiEvent[] {
- if (!this.enabled) return [];
- const events = [...this.drainChannel(this.reasoning), ...this.drainChannel(this.text)];
- this.logSummary();
- return events;
- }
-
- private processDelta(
- event: Extract,
- channel: Channel,
- otherChannel: Channel,
- ): InstanceAiEvent[] {
- const events: InstanceAiEvent[] = [];
- // Channel switch: release the other channel's held-back tail first so
- // true chronological order is kept. The model emits one channel at a
- // time (a switch is a content-block boundary), so a secret never spans
- // it and the drain is safe.
- events.push(...this.drainChannel(otherChannel));
- // A new step: release the previous step's text under its own responseId.
- if (channel.responseId !== undefined && channel.responseId !== event.responseId) {
- events.push(...this.drainChannel(channel));
- }
- channel.responseId = event.responseId;
-
- const { text, matches } = channel.redactor.push(event.payload.text);
- this.recordMatches(matches);
- if (text) events.push(this.makeDelta(channel, text));
- return events;
- }
-
- private drainChannel(channel: Channel): InstanceAiEvent[] {
- const { text, matches } = channel.redactor.flush();
- this.recordMatches(matches);
- return text ? [this.makeDelta(channel, text)] : [];
- }
-
- private makeDelta(channel: Channel, text: string): InstanceAiEvent {
- return {
- type: channel.type,
- runId: this.context.runId,
- agentId: this.context.agentId,
- ...(channel.responseId ? { responseId: channel.responseId } : {}),
- payload: { text },
- };
- }
-
- private redactStructural(event: InstanceAiEvent): InstanceAiEvent {
- if (event.type === 'tool-call') {
- // Redact the model-generated args shown in the UI. This only touches the
- // event payload, not the actual tool invocation (handled by the runtime).
- const { value, matches } = redactDeep(event.payload.args, this.options);
- this.recordMatches(matches);
- return {
- ...event,
- payload: { ...event.payload, args: isRecord(value) ? value : event.payload.args },
- };
- }
- if (event.type === 'tool-result') {
- const { value, matches } = redactDeep(event.payload.result, this.options);
- this.recordMatches(matches);
- return { ...event, payload: { ...event.payload, result: value } };
- }
- if (event.type === 'tool-error') {
- return {
- ...event,
- payload: { ...event.payload, error: this.redactString(event.payload.error) },
- };
- }
- if (event.type === 'confirmation-request') {
- return this.redactConfirmation(event);
- }
- return event;
- }
-
- /**
- * Redact the human-readable content of a HITL confirmation card (message, intro,
- * question/option labels, task/plan-item descriptions, and target-tool labels/args). Control and
- * identifier fields — `requestId`, `toolCallId`, `inputType`,
- * `credentialRequests`, task `id`/`status`, plan `kind`/`deps`, etc. — are
- * left untouched so suspend/resume routing keeps working.
- */
- private redactConfirmation(
- event: Extract,
- ): InstanceAiEvent {
- const payload = event.payload;
- const questions = payload.questions?.map((question) => ({
- ...question,
- question: this.redactString(question.question),
- ...(question.options ? { options: question.options.map((o) => this.redactString(o)) } : {}),
- }));
-
- const tasks = payload.tasks
- ? {
- ...payload.tasks,
- tasks: payload.tasks.tasks.map((task) => ({
- ...task,
- description: this.redactString(task.description),
- ...(task.detail ? { detail: this.redactString(task.detail) } : {}),
- })),
- }
- : undefined;
-
- const planItems = payload.planItems?.map((item) => ({
- ...item,
- title: this.redactString(item.title),
- spec: this.redactString(item.spec),
- }));
- let targetApproval = payload.targetApproval;
- if (targetApproval) {
- const boundedArgs = withholdDeepTargetApprovalArgs(targetApproval.args);
- const { value, matches } = redactDeep(boundedArgs, this.options);
- this.recordMatches(matches);
- targetApproval = {
- ...targetApproval,
- toolName: this.redactString(targetApproval.toolName),
- ...(targetApproval.displayName
- ? { displayName: this.redactString(targetApproval.displayName) }
- : {}),
- args: value,
- };
- }
-
- return {
- ...event,
- payload: {
- ...payload,
- message: this.redactString(payload.message),
- ...(payload.introMessage ? { introMessage: this.redactString(payload.introMessage) } : {}),
- ...(questions ? { questions } : {}),
- ...(tasks ? { tasks } : {}),
- ...(planItems ? { planItems } : {}),
- ...(targetApproval ? { targetApproval } : {}),
- },
- };
- }
-
- private redactString(text: string): string {
- const { text: redacted, matches } = redactText(text, this.options);
- this.recordMatches(matches);
- return redacted;
- }
-
- private recordMatches(matches: Array<{ category: RedactionCategory }>): void {
- for (const match of matches) this.matches.push(match.category);
- }
-
- /** Emit a single filtering-event log per segment — categories and counts only, never values. */
- private logSummary(): void {
- if (this.matches.length === 0) return;
- const categories: Partial> = {};
- for (const category of this.matches) {
- categories[category] = (categories[category] ?? 0) + 1;
- }
- this.context.logger.info('Instance AI redacted sensitive content from agent output', {
- threadId: this.context.threadId,
- runId: this.context.runId,
- agentId: this.context.agentId,
- count: this.matches.length,
- categories,
- });
- this.matches = [];
- }
-}
diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts
index 0d5eb879ba8..735f833a0b4 100644
--- a/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts
+++ b/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts
@@ -187,7 +187,6 @@ export function startEvalSetupAgentTask(
eventBus: context.eventBus,
logger: context.logger,
threadId: context.threadId,
- outputRedaction: context.outputRedaction,
abortSignal: signal,
waitForConfirmation: context.waitForConfirmation,
drainCorrections,
diff --git a/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts b/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts
index 5556899a8b7..c86ac66bb6c 100644
--- a/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts
+++ b/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts
@@ -62,8 +62,8 @@ function isStructuralTelemetryIdKey(key: string): boolean {
}
/**
- * Telemetry/tracing redaction policy. Deliberately stricter than the
- * user-facing output policy `DEFAULT_OUTPUT_REDACTION_OPTIONS`.
+ * Telemetry/tracing redaction policy: secrets plus every supported PII
+ * category, since traces egress to third-party tooling.
* `preserveUrlStructure`: whole-URL redaction destroyed traced workflow
* definitions without adding protection (secrets in URLs are caught first).
*/
diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts
index f24048f57ad..4df5e134ea9 100644
--- a/packages/@n8n/instance-ai/src/types.ts
+++ b/packages/@n8n/instance-ai/src/types.ts
@@ -5,7 +5,6 @@ import type {
BuiltTool,
CheckpointStore,
MemoryTaskUsageReport,
- RedactionOptions,
RuntimeSkillSource,
ModelConfig as NativeModelConfig,
ScopedMemoryTaskEvent,
@@ -1699,12 +1698,6 @@ export interface OrchestrationContext {
checkpointStore?: CheckpointStore;
eventBus: InstanceAiEventBus;
logger: Logger;
- /**
- * Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
- * default policy, which on the durable-log path would persist redacted text
- * — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
- */
- outputRedaction?: RedactionOptions | false;
trackTelemetry?: (eventName: string, properties: Record) => void;
/**
* Claim AI credits for a sub-agent stream segment. Wired by the host (cli);
diff --git a/packages/cli/src/events/maps/instance-ai.event-map.ts b/packages/cli/src/events/maps/instance-ai.event-map.ts
index 3edcb9649d2..ad67ccb3e39 100644
--- a/packages/cli/src/events/maps/instance-ai.event-map.ts
+++ b/packages/cli/src/events/maps/instance-ai.event-map.ts
@@ -26,10 +26,6 @@ export type InstanceAiEventMap = {
latencyMs: number;
trees: number;
};
- /** History rendered from the message-derived fallback ladder instead of a renderable snapshot tree. */
- 'instance-ai-parser-fallback': {
- count: number;
- };
/** The interrupted-run sweep resolved a crashed run. */
'instance-ai-run-swept': {
outcome: 'interrupted' | 'crash-resumed';
diff --git a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts
index 33dd37b21c5..ceb368c50cd 100644
--- a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts
+++ b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts
@@ -123,12 +123,6 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5],
});
- const parserFallbacksTotal = new promClient.Counter({
- name: `${this.config.prefix}instance_ai_parser_fallbacks_total`,
- help: 'History messages rendered from the message-derived fallback ladder instead of a renderable snapshot tree.',
- });
- parserFallbacksTotal.inc(0);
-
const runsSweptTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_runs_swept_total`,
help: 'Crashed Instance AI runs resolved by the interrupted-run sweep.',
@@ -157,9 +151,6 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
this.eventService.on('instance-ai-history-folded', ({ latencyMs }) => {
historyFoldDuration.observe(latencyMs / 1000);
});
- this.eventService.on('instance-ai-parser-fallback', ({ count }) => {
- parserFallbacksTotal.inc(count);
- });
this.eventService.on('instance-ai-run-swept', ({ outcome }) => {
runsSweptTotal.inc({ outcome }, 1);
});
diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts
index 469954a30db..a21f58bdb10 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts
@@ -1,5 +1,3 @@
-import type { InstanceAiAgentNode } from '@n8n/api-types';
-
import { InstanceAiMemoryService } from '../instance-ai-memory.service';
const mockListMessages = vi.fn();
@@ -26,7 +24,6 @@ const mockAgentMemory = {
};
// Mock GlobalConfig
-const mockDbSnapshotStorage = { getForWindow: vi.fn().mockResolvedValue([]) };
const mockCheckpointRepository = { findActiveByThreadId: vi.fn().mockResolvedValue([]) };
interface LogRow {
@@ -81,7 +78,7 @@ function installLogDouble(rows: LogRow[] = []): void {
logRows.filter((row) => runIds.includes(row.runId)),
);
}
-const mockDurableLogMetrics = { recordFoldRead: vi.fn(), notifyParserFallbacks: vi.fn() };
+const mockDurableLogMetrics = { recordFoldRead: vi.fn() };
function createService(options: { threadTtlDays?: number } = {}): InstanceAiMemoryService {
const mockConfig = {
@@ -104,7 +101,6 @@ function createService(options: { threadTtlDays?: number } = {}): InstanceAiMemo
mockLogger as never,
mockConfig as never,
mockAgentMemory as never,
- mockDbSnapshotStorage as never,
mockCheckpointRepository as never,
mockPendingConfirmationRepository as never,
mockEventLogRepository as never,
@@ -116,20 +112,6 @@ const mockPendingConfirmationRepository = {
findLiveRequestIds: vi.fn(async () => new Set()),
};
-function makeTree(overrides?: Partial): InstanceAiAgentNode {
- return {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'completed',
- textContent: 'Done!',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [{ type: 'text', content: 'Done!' }],
- ...overrides,
- };
-}
-
function makeThread(id: string, updatedAt: string) {
return {
id,
@@ -144,92 +126,10 @@ function makeThread(id: string, updatedAt: string) {
describe('InstanceAiMemoryService.getRichMessages', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([]);
+ installLogDouble();
mockListMessages.mockResolvedValue({ messages: [] });
});
- it('should return parsed rich messages with agent trees from snapshots', async () => {
- const tree = makeTree();
- mockListMessages.mockResolvedValue({
- messages: [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Hello',
- createdAt: new Date('2026-01-01T00:00:00.000Z'),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [{ type: 'text', text: 'Done!' }],
- createdAt: new Date('2026-01-01T00:00:01.000Z'),
- },
- ],
- });
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- {
- tree,
- runId: 'run_abc',
- createdAt: new Date('2026-01-01T00:00:01.000Z'),
- updatedAt: new Date('2026-01-01T00:00:01.000Z'),
- },
- ]);
-
- const service = createService();
- const result = await service.getRichMessages('user-1', 'thread-1');
-
- expect(result.messages).toHaveLength(2);
- expect(result.messages[0].role).toBe('user');
- expect(result.messages[0].content).toBe('Hello');
- expect(result.messages[1].role).toBe('assistant');
- expect(result.messages[1].agentTree).toStrictEqual(tree);
- expect(result.messages[1].runId).toBe('run_abc');
- });
-
- it('should return parsed messages with flat tree when no snapshots exist', async () => {
- mockListMessages.mockResolvedValue({
- messages: [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Hi',
- createdAt: new Date('2026-01-01T00:00:00.000Z'),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- { type: 'text', text: 'Here are your workflows' },
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'list-workflows',
- input: {},
- state: 'resolved',
- output: { workflows: [] },
- },
- ],
- createdAt: new Date('2026-01-01T00:00:01.000Z'),
- },
- ],
- });
- mockGetThread.mockResolvedValue({
- id: 'thread-1',
- title: 'Test',
- metadata: {},
- });
-
- const service = createService();
- const result = await service.getRichMessages('user-1', 'thread-1');
-
- expect(result.messages).toHaveLength(2);
- const assistant = result.messages[1];
- expect(assistant.agentTree).toBeDefined();
- expect(assistant.agentTree?.toolCalls).toHaveLength(1);
- expect(assistant.agentTree?.toolCalls[0].toolName).toBe('list-workflows');
- expect(assistant.agentTree?.toolCalls[0].isLoading).toBe(false);
- });
-
it('should handle empty message list', async () => {
mockListMessages.mockResolvedValue({ messages: [] });
mockGetThread.mockResolvedValue({
@@ -244,95 +144,6 @@ describe('InstanceAiMemoryService.getRichMessages', () => {
expect(result.messages).toEqual([]);
});
- it('surfaces in-flight checkpoint messages not yet committed to memory', async () => {
- // A turn that suspended at HITL never gets `saveToMemory` called by the
- // SDK. The inbound user message is persisted on receipt, but the
- // intermediate assistant messages (and any pending tool-call) live only
- // in `state.messageList.messages` until the turn resumes and completes.
- // The /messages endpoint should surface them so a page reload doesn't
- // drop in-flight artifacts.
- mockListMessages.mockResolvedValue({ messages: [] });
- mockCheckpointRepository.findActiveByThreadId.mockResolvedValueOnce([
- {
- key: 'run_abc',
- runId: 'run_abc',
- threadId: 'thread-1',
- expiredAt: null,
- state: {
- messageList: {
- messages: [
- {
- id: 'cp-user-1',
- role: 'user',
- content: [{ type: 'text', text: 'execute my workflow' }],
- createdAt: '2026-01-01T00:00:00.000Z',
- },
- {
- id: 'cp-assistant-1',
- role: 'assistant',
- content: [{ type: 'text', text: 'On it!' }],
- createdAt: '2026-01-01T00:00:01.000Z',
- },
- ],
- },
- },
- createdAt: new Date('2026-01-01T00:00:01.000Z'),
- updatedAt: new Date('2026-01-01T00:00:01.000Z'),
- },
- ]);
-
- const service = createService();
- const result = await service.getRichMessages('user-1', 'thread-1');
-
- expect(result.messages).toHaveLength(2);
- expect(result.messages[0]).toMatchObject({ id: 'cp-user-1', role: 'user' });
- expect(result.messages[0].content).toBe('execute my workflow');
- expect(result.messages[1]).toMatchObject({ id: 'cp-assistant-1', role: 'assistant' });
- });
-
- it('prefers stored messages over checkpoint duplicates with the same id', async () => {
- // When a previously suspended turn resumes and commits its messages to
- // memory, the same IDs appear in both places. The stored row wins so
- // any post-suspension edits the SDK made (e.g. final tool outcomes)
- // are not regressed by the stale checkpoint copy.
- mockListMessages.mockResolvedValue({
- messages: [
- {
- id: 'msg-1',
- role: 'user',
- content: [{ type: 'text', text: 'final version' }],
- createdAt: new Date('2026-01-01T00:00:00.000Z'),
- },
- ],
- });
- mockCheckpointRepository.findActiveByThreadId.mockResolvedValueOnce([
- {
- key: 'run_abc',
- expiredAt: null,
- state: {
- messageList: {
- messages: [
- {
- id: 'msg-1',
- role: 'user',
- content: [{ type: 'text', text: 'stale checkpoint copy' }],
- createdAt: '2026-01-01T00:00:00.000Z',
- },
- ],
- },
- },
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- ]);
-
- const service = createService();
- const result = await service.getRichMessages('user-1', 'thread-1');
-
- expect(result.messages).toHaveLength(1);
- expect(result.messages[0].content).toBe('final version');
- });
-
it('tolerates a missing or unreadable checkpoint store', async () => {
mockListMessages.mockResolvedValue({
messages: [
@@ -402,64 +213,11 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
beforeEach(() => {
vi.clearAllMocks();
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([]);
installLogDouble();
mockListMessages.mockResolvedValue({ messages: [userMessage, assistantMessage] });
});
- it('derives the tree from the log even when the stored snapshot is degenerate', async () => {
- // The stored snapshot was built over an evicted buffer: an empty
- // cancelled tree with none of the run's work (the INS-595 bug family).
- // Snapshot rows keep being written, but they are never read once the
- // thread has log rows.
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- {
- tree: makeTree({ status: 'cancelled', textContent: '', timeline: [], toolCalls: [] }),
- runId: 'run_abc',
- createdAt: at,
- updatedAt: at,
- },
- ]);
- setLogRows([
- eventRow(
- {
- type: 'run-start',
- runId: 'run_abc',
- agentId: 'agent-001',
- payload: { messageId: 'm-1' },
- },
- at,
- ),
- ...toolCallRows('run_abc', 3),
- eventRow(
- {
- type: 'run-finish',
- runId: 'run_abc',
- agentId: 'agent-001',
- payload: { status: 'completed' },
- },
- at,
- ),
- ]);
-
- const service = createService();
- const result = await service.getRichMessages('user-1', 'thread-1');
-
- const assistant = result.messages[1];
- expect(assistant.agentTree?.toolCalls).toHaveLength(3);
- expect(assistant.agentTree?.toolCalls.map((tc) => tc.toolName)).toEqual([
- 'tool-1',
- 'tool-2',
- 'tool-3',
- ]);
- expect(assistant.agentTree?.status).toBe('completed');
- expect(mockDurableLogMetrics.recordFoldRead).toHaveBeenCalledWith(expect.any(Number), 1);
- // The stored rows are not even loaded: the snapshot query only runs when
- // the fold needs its fallback.
- expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
- });
-
- it('renders a run that crashed before its snapshot was written', async () => {
+ it('derives the agent tree for a completed run from the log', async () => {
setLogRows([
eventRow(
{
@@ -565,16 +323,11 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
expect(result.messages[1].agentTree?.toolCalls).toHaveLength(1);
});
- it('renders nothing for a fold emptied by exclusion instead of falling back to stored snapshots', async () => {
- // The in-flight group is the thread's ONLY log content. Its completed
- // sibling run_a has a stored snapshot that would survive the loader's
- // exact-runId filter (only run_b is excluded) — falling back would
- // resurrect exactly the in-flight group state the exclusion keeps out
- // of history.
+ it('renders nothing for a fold emptied by exclusion', async () => {
+ // The in-flight group is the thread's ONLY log content. Excluding run_b
+ // poisons the whole group, so its completed sibling run_a must not derive
+ // a partial tree — the in-flight turn renders live via SSE, not history.
mockListMessages.mockResolvedValue({ messages: [userMessage] });
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- { tree: makeTree(), runId: 'run_a', createdAt: at, updatedAt: at },
- ]);
setLogRows([
eventRow(
{
@@ -611,7 +364,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
excludeRunIds: ['run_b'],
});
- expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
});
@@ -651,7 +403,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
});
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
- expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
});
@@ -706,10 +457,9 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
// Only run_done's entry is derived, exactly as the driving main would.
expect(mockDurableLogMetrics.recordFoldRead).toHaveBeenCalledWith(expect.any(Number), 1);
expect(result.messages[1].agentTree?.toolCalls).toHaveLength(1);
- expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
});
- it('renders nothing when the only group is in flight on another main instead of falling back', async () => {
+ it('renders nothing when the only group is in flight on another main', async () => {
mockListMessages.mockResolvedValue({ messages: [userMessage] });
setLogRows([
eventRow(
@@ -735,15 +485,15 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
- expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
});
it('keeps folding a HITL-suspended run without a run-finish', async () => {
// A suspended run legitimately never wrote its run-finish, but its turn
- // must keep rendering: the fold entry pairs with the checkpoint-surfaced
- // assistant message. The suspension is recognized by the run's own
+ // must keep rendering: with no assistant rows committed yet, the folded
+ // entry surfaces as a standalone assistant message carrying the
+ // confirmation card. The suspension is recognized by the run's own
// checkpoint — the same predicate that spares it from the interrupted-run
// sweep.
mockListMessages.mockResolvedValue({ messages: [userMessage] });
@@ -754,19 +504,7 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
hostRunId: 'run_susp',
threadId: 'thread-1',
expiredAt: null,
- state: {
- status: 'suspended',
- messageList: {
- messages: [
- {
- id: 'cp-assistant-1',
- role: 'assistant',
- content: [{ type: 'text', text: 'Confirm before I continue' }],
- createdAt: '2026-01-01T00:00:01.000Z',
- },
- ],
- },
- },
+ state: { status: 'suspended' },
createdAt: new Date('2026-01-01T00:00:01.000Z'),
updatedAt: new Date('2026-01-01T00:00:01.000Z'),
},
@@ -806,6 +544,7 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
expect(result.messages).toHaveLength(2);
const assistant = result.messages[1];
+ expect(assistant.role).toBe('assistant');
expect(assistant.agentTree?.toolCalls[0]?.confirmation?.requestId).toBe('req-1');
});
@@ -989,39 +728,32 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
expect(texts).toEqual(['one', 'two', 'three']);
});
- it('keeps the stored snapshot tree for pre-log threads (no log rows)', async () => {
- const tree = makeTree();
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- { tree, runId: 'run_abc', createdAt: at, updatedAt: at },
- ]);
-
+ it('renders messages without trees for pre-log threads (no log rows)', async () => {
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
- expect(result.messages[1].agentTree).toStrictEqual(tree);
+ // No run-start facts -> nothing folds; the assistant message renders from
+ // its own content blocks only (no fold-provided runIds).
+ expect(result.messages).toHaveLength(2);
+ expect(result.messages[1].runIds).toBeUndefined();
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
});
- it('falls back to stored snapshots when the log read fails', async () => {
- const tree = makeTree();
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- { tree, runId: 'run_abc', createdAt: at, updatedAt: at },
- ]);
+ it('renders messages without trees when the log read fails', async () => {
mockEventLogRepository.getRunStarts.mockRejectedValue(new Error('db down'));
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
- expect(result.messages[1].agentTree).toStrictEqual(tree);
+ // Degrades to messages-without-trees rather than failing the page read.
+ expect(result.messages).toHaveLength(2);
+ expect(result.messages[1].runIds).toBeUndefined();
+ expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
});
- it('falls back to stored snapshots when the log derives nothing renderable', async () => {
- const tree = makeTree();
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- { tree, runId: 'run_abc', createdAt: at, updatedAt: at },
- ]);
+ it('renders messages without trees when the log derives nothing renderable', async () => {
// The log holds only lifecycle facts — no renderable work, so no orphan
- // card is derived and the stored snapshots keep rendering.
+ // card is derived.
setLogRows([
eventRow(
{
@@ -1046,7 +778,8 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
- expect(result.messages[1].agentTree).toStrictEqual(tree);
+ expect(result.messages).toHaveLength(2);
+ expect(result.messages[1].runIds).toBeUndefined();
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
});
@@ -1159,30 +892,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
});
});
- it('keeps the tree of the turn an older page ends on', async () => {
- // The stored-snapshot path (durable log off) reads by snapshot
- // createdAt, which lands just after the assistant row it pairs with.
- const snapshotAt = new Date('2026-01-01T00:00:01.300Z');
- const nextPageAt = new Date('2026-01-01T00:00:05.000Z');
- const tree = makeTree();
- mockListMessages.mockResolvedValue({
- messages: [userMessage, assistantMessage],
- newerBoundaryAt: nextPageAt,
- });
- mockDbSnapshotStorage.getForWindow.mockResolvedValue([
- { tree, runId: 'run_abc', createdAt: snapshotAt, updatedAt: snapshotAt },
- ]);
-
- const service = createService();
- const result = await service.getRichMessages('user-1', 'thread-1', { page: 1 });
-
- expect(mockDbSnapshotStorage.getForWindow).toHaveBeenCalledWith('thread-1', {
- since: userMessage.createdAt,
- before: nextPageAt,
- });
- expect(result.messages[1].agentTree).toBe(tree);
- });
-
it('hydrates nothing for an out-of-range older page', async () => {
// No message rows to pair a tree with, and no bounds to read one
// with: an unbounded read here would hydrate the whole thread to
@@ -1194,7 +903,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
const result = await service.getRichMessages('user-1', 'thread-1', { page: 3 });
expect(mockEventLogRepository.findRunIdsInWindow).not.toHaveBeenCalled();
- expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toEqual([]);
});
@@ -1248,15 +956,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
since: userMessage.createdAt,
});
});
-
- it('windows the stored-snapshot path too', async () => {
- const service = createService();
- await service.getRichMessages('user-1', 'thread-1');
-
- expect(mockDbSnapshotStorage.getForWindow).toHaveBeenCalledWith('thread-1', {
- since: userMessage.createdAt,
- });
- });
});
});
diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts
index ee36c9225b8..6631d8dfef1 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts
@@ -100,7 +100,7 @@ vi.mock('@n8n/instance-ai', () => ({
}));
import type { Mock } from 'vitest';
-import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
+import type { InstanceAiEvent } from '@n8n/api-types';
import type { ManagedBackgroundTask, TerminalOutcome } from '@n8n/instance-ai';
import {
@@ -108,15 +108,6 @@ import {
type InstanceAiTerminalOutcomeServiceOptions,
} from '../instance-ai-terminal-outcome.service';
-type SnapshotRow = {
- tree: InstanceAiAgentNode;
- runId: string;
- messageGroupId?: string;
- runIds?: string[];
- langsmithRunId?: string;
- langsmithTraceId?: string;
-};
-
type Deps = {
eventBus: {
events: InstanceAiEvent[];
@@ -124,11 +115,6 @@ type Deps = {
getEventsForRuns: Mock;
publish: Mock;
};
- dbSnapshotStorage: {
- getLatest: Mock;
- save: Mock;
- updateLast: Mock;
- };
telemetry: { track: Mock };
errorReporter: { report: Mock };
logger: { warn: Mock; debug: Mock; error: Mock };
@@ -136,7 +122,6 @@ type Deps = {
suspendedThreads: { dropPendingConfirmationsForThread: Mock };
tracing: { finalizeRunTracing: Mock; buildMessageTraceMetadata: Mock };
publishRunFinish: Mock;
- saveAgentTreeSnapshot: Mock;
};
function makeTerminalOutcome(overrides: Partial = {}): TerminalOutcome {
@@ -155,20 +140,7 @@ function makeTerminalOutcome(overrides: Partial = {}): Terminal
};
}
-function makeAgentTree(): InstanceAiAgentNode {
- return {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'completed',
- textContent: 'Initial response',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [{ type: 'text', content: 'Initial response' }],
- };
-}
-
-function createService(snapshotTree?: InstanceAiAgentNode): {
+function createService(): {
service: InstanceAiTerminalOutcomeService;
deps: Deps;
} {
@@ -182,16 +154,6 @@ function createService(snapshotTree?: InstanceAiAgentNode): {
events.push(event);
}),
},
- dbSnapshotStorage: {
- getLatest: vi.fn(
- async (): Promise =>
- snapshotTree
- ? { tree: snapshotTree, runId: 'run-1', messageGroupId: 'group-1', runIds: ['run-1'] }
- : undefined,
- ),
- save: vi.fn(async () => {}),
- updateLast: vi.fn(async () => {}),
- },
telemetry: { track: vi.fn() },
errorReporter: { report: vi.fn() },
logger: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
@@ -214,12 +176,10 @@ function createService(snapshotTree?: InstanceAiAgentNode): {
} as InstanceAiEvent);
},
),
- saveAgentTreeSnapshot: vi.fn(async () => {}),
};
const options = {
eventBus: deps.eventBus,
- dbSnapshotStorage: deps.dbSnapshotStorage,
agentMemory: {},
telemetry: deps.telemetry,
errorReporter: deps.errorReporter,
@@ -228,7 +188,6 @@ function createService(snapshotTree?: InstanceAiAgentNode): {
suspendedThreads: deps.suspendedThreads,
tracing: deps.tracing,
publishRunFinish: deps.publishRunFinish,
- saveAgentTreeSnapshot: deps.saveAgentTreeSnapshot,
} as unknown as InstanceAiTerminalOutcomeServiceOptions;
return { service: new InstanceAiTerminalOutcomeService(options), deps };
@@ -242,104 +201,13 @@ beforeEach(() => {
});
describe('InstanceAiTerminalOutcomeService — terminal outcome replay', () => {
- it('replays undelivered background outcomes into the persisted agent tree', async () => {
+ it('publishes recovered background outcomes and marks them delivered', async () => {
const outcome = makeTerminalOutcome();
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
- const { service, deps } = createService(makeAgentTree());
-
- await service.replayUndeliveredTerminalOutcomes('thread-a');
-
- expect(deps.dbSnapshotStorage.updateLast).toHaveBeenCalledTimes(1);
- const updatedTree = deps.dbSnapshotStorage.updateLast.mock.calls[0][1] as InstanceAiAgentNode;
- expect(updatedTree.textContent).toContain(outcome.userFacingMessage);
- expect(updatedTree.timeline).toContainEqual({
- type: 'text',
- content: outcome.userFacingMessage,
- responseId: `background-outcome:${outcome.id}`,
- });
- expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
- 'thread-a',
- outcome.id,
- expect.any(String),
- );
- expect(deps.eventBus.publish).not.toHaveBeenCalled();
- });
-
- it('publishes recovered background outcomes when replaying for SSE delivery', async () => {
- const outcome = makeTerminalOutcome();
- terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
- const { service, deps } = createService(makeAgentTree());
-
- await service.replayUndeliveredTerminalOutcomes('thread-a', { delivery: 'event' });
-
- expect(deps.dbSnapshotStorage.updateLast).toHaveBeenCalledTimes(1);
- expect(deps.eventBus.publish).toHaveBeenCalledWith('thread-a', {
- type: 'text-block',
- runId: outcome.runId,
- agentId: 'orchestrator-run-1',
- responseId: `background-outcome:${outcome.id}`,
- payload: { text: outcome.userFacingMessage },
- });
- expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
- 'thread-a',
- outcome.id,
- expect.any(String),
- );
- });
-
- it('deduplicates replay by response id only', async () => {
- const outcome = makeTerminalOutcome({ id: 'group-1:task-2:completed' });
- const tree = makeAgentTree();
- tree.textContent = `${tree.textContent}\n\n${outcome.userFacingMessage}`;
- tree.timeline.push({
- type: 'text',
- content: outcome.userFacingMessage,
- responseId: 'background-outcome:different-id',
- });
- terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
- const { service, deps } = createService(tree);
-
- await service.replayUndeliveredTerminalOutcomes('thread-a');
-
- const updatedTree = deps.dbSnapshotStorage.updateLast.mock.calls[0][1] as InstanceAiAgentNode;
- expect(
- updatedTree.timeline.filter(
- (entry) => entry.type === 'text' && entry.content === outcome.userFacingMessage,
- ),
- ).toHaveLength(2);
- expect(updatedTree.timeline).toContainEqual({
- type: 'text',
- content: outcome.userFacingMessage,
- responseId: `background-outcome:${outcome.id}`,
- });
- });
-
- it('creates a snapshot when replay has no prior agent tree', async () => {
- const outcome = makeTerminalOutcome({ status: 'failed' });
- terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService();
await service.replayUndeliveredTerminalOutcomes('thread-a');
- expect(deps.dbSnapshotStorage.save).toHaveBeenCalledTimes(1);
- const savedTree = deps.dbSnapshotStorage.save.mock.calls[0][1] as InstanceAiAgentNode;
- expect(savedTree.status).toBe('error');
- expect(savedTree.textContent).toBe(outcome.userFacingMessage);
- expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
- 'thread-a',
- outcome.id,
- expect.any(String),
- );
- });
-
- it('publishes the deterministic line when snapshot replay fails', async () => {
- const outcome = makeTerminalOutcome();
- terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
- const { service, deps } = createService(makeAgentTree());
- deps.dbSnapshotStorage.updateLast.mockRejectedValue(new Error('storage unavailable'));
-
- await service.replayUndeliveredTerminalOutcomes('thread-a', { delivery: 'event' });
-
expect(deps.eventBus.publish).toHaveBeenCalledWith('thread-a', {
type: 'text-block',
runId: outcome.runId,
@@ -347,7 +215,80 @@ describe('InstanceAiTerminalOutcomeService — terminal outcome replay', () => {
responseId: `background-outcome:${outcome.id}`,
payload: { text: outcome.userFacingMessage },
});
+ expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
+ 'thread-a',
+ outcome.id,
+ expect.any(String),
+ );
+ expect(deps.telemetry.track).toHaveBeenCalledWith(
+ 'instance_ai_terminal_response_decision',
+ expect.objectContaining({ source: 'terminal_outcome_replay', action: 'replay_event' }),
+ );
+ });
+
+ it('deduplicates replay by response id only', async () => {
+ const outcome = makeTerminalOutcome({ id: 'group-1:task-2:completed' });
+ terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
+ const { service, deps } = createService();
+ // Same text under a different response id must not suppress the replay.
+ deps.eventBus.events.push({
+ type: 'text-block',
+ runId: outcome.runId,
+ agentId: 'orchestrator-run-1',
+ responseId: 'background-outcome:different-id',
+ payload: { text: outcome.userFacingMessage },
+ } as InstanceAiEvent);
+
+ await service.replayUndeliveredTerminalOutcomes('thread-a');
+
+ expect(deps.eventBus.publish).toHaveBeenCalledTimes(1);
+ expect(deps.eventBus.publish).toHaveBeenCalledWith(
+ 'thread-a',
+ expect.objectContaining({ responseId: `background-outcome:${outcome.id}` }),
+ );
+
+ // A second replay finds the exact response id already emitted and skips it.
+ await service.replayUndeliveredTerminalOutcomes('thread-a');
+
+ expect(deps.eventBus.publish).toHaveBeenCalledTimes(1);
+ expect(deps.telemetry.track).toHaveBeenCalledWith(
+ 'instance_ai_terminal_response_decision',
+ expect.objectContaining({ source: 'terminal_outcome_replay', action: 'already-emitted' }),
+ );
+ });
+
+ it('leaves the outcome undelivered when publishing the line fails', async () => {
+ const outcome = makeTerminalOutcome();
+ terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
+ const { service, deps } = createService();
+ deps.eventBus.publish.mockImplementation(() => {
+ throw new Error('bus unavailable');
+ });
+
+ await service.replayUndeliveredTerminalOutcomes('thread-a');
+
expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
+ expect(deps.logger.warn).toHaveBeenCalledWith(
+ 'Failed to replay Instance AI terminal outcome',
+ expect.objectContaining({ threadId: 'thread-a', runId: outcome.runId }),
+ );
+ });
+
+ it('leaves the outcome undelivered when the drain drops the published line', async () => {
+ const outcome = makeTerminalOutcome();
+ terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
+ const { service, deps } = createService();
+ // The publish enqueues without throwing, but the line never reaches the
+ // log — the shape of a dropped drain batch.
+ deps.eventBus.publish.mockImplementation(() => {});
+
+ await service.replayUndeliveredTerminalOutcomes('thread-a');
+
+ expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
+ expect(deps.telemetry.track).not.toHaveBeenCalledWith(
+ 'instance_ai_terminal_response_decision',
+ expect.anything(),
+ );
});
it('checks persisted outcomes on repeated replay calls', async () => {
@@ -380,7 +321,7 @@ describe('InstanceAiTerminalOutcomeService — background outcome recording', ()
}
it('persists, publishes, and marks the outcome delivered on success', async () => {
- const { service, deps } = createService(makeAgentTree());
+ const { service, deps } = createService();
await service.recordBackgroundTerminalOutcome(makeTask());
@@ -392,13 +333,12 @@ describe('InstanceAiTerminalOutcomeService — background outcome recording', ()
payload: { text: 'The background workflow-builder task finished.' },
}),
);
- expect(deps.dbSnapshotStorage.updateLast).toHaveBeenCalledTimes(1);
expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledTimes(1);
});
it('keeps the outcome pending and replays it later when persistence fails', async () => {
terminalOutcomeStorageMock.upsert.mockRejectedValueOnce(new Error('db down'));
- const { service, deps } = createService(makeAgentTree());
+ const { service, deps } = createService();
await service.recordBackgroundTerminalOutcome(makeTask());
@@ -412,16 +352,33 @@ describe('InstanceAiTerminalOutcomeService — background outcome recording', ()
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
});
+
+ it('does not mark the outcome delivered when the drain drops the line', async () => {
+ const { service, deps } = createService();
+ deps.eventBus.publish.mockImplementation(() => {});
+
+ await service.recordBackgroundTerminalOutcome(makeTask());
+
+ expect(terminalOutcomeStorageMock.upsert).toHaveBeenCalledTimes(1);
+ expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
+ expect(deps.telemetry.track).toHaveBeenCalledWith(
+ 'instance_ai_terminal_outcome_persistence_failure',
+ expect.objectContaining({ phase: 'event' }),
+ );
+ expect(deps.telemetry.track).not.toHaveBeenCalledWith(
+ 'instance_ai_terminal_response_decision',
+ expect.anything(),
+ );
+ });
});
describe('InstanceAiTerminalOutcomeService — durable-log outcome lines', () => {
it('publishes the outcome line as a persisted text-block, not a trailing delta', async () => {
// A trailing delta would race the coalescer's idle flush on an immediate
// page reload; a text-block is persisted before it is emitted live.
- const { deps } = createService(makeAgentTree());
+ const { deps } = createService();
const service = new InstanceAiTerminalOutcomeService({
eventBus: deps.eventBus,
- dbSnapshotStorage: deps.dbSnapshotStorage,
agentMemory: {},
telemetry: deps.telemetry,
logger: deps.logger,
@@ -429,7 +386,6 @@ describe('InstanceAiTerminalOutcomeService — durable-log outcome lines', () =>
suspendedThreads: deps.suspendedThreads,
tracing: deps.tracing,
publishRunFinish: deps.publishRunFinish,
- saveAgentTreeSnapshot: vi.fn(async () => {}),
} as never);
await service.recordBackgroundTerminalOutcome({
@@ -547,7 +503,6 @@ describe('InstanceAiTerminalOutcomeService — terminal response guard wiring',
threadId: 'thread-a',
runId: 'run-1',
abortController,
- snapshotStorage: {} as never,
});
expect(finalization.status).toBe('error');
@@ -557,7 +512,6 @@ describe('InstanceAiTerminalOutcomeService — terminal response guard wiring',
'thread-a',
);
expect(abortController.signal.aborted).toBe(true);
- expect(deps.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
expect(deps.eventBus.events.at(-1)).toMatchObject({
type: 'run-finish',
payload: { status: 'error' },
diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts
index 156ce874acb..c2581c9eda7 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts
@@ -19,9 +19,9 @@ vi.mock('@n8n/instance-ai', () => ({
import {
InstanceAiTracingService,
type InstanceAiTracingAiService,
+ type InstanceAiTracingEventLog,
type InstanceAiTracingEventReader,
type InstanceAiTracingRunState,
- type InstanceAiTracingSnapshotStorage,
} from '../tracing';
type FakeTraceRun = {
@@ -54,7 +54,7 @@ function createService(
logger?: Partial;
eventReader?: Partial;
runState?: Partial;
- dbSnapshotStorage?: Partial;
+ eventLog?: Partial;
aiService?: Partial;
} = {},
) {
@@ -67,9 +67,9 @@ function createService(
attachTracing: vi.fn(),
...overrides.runState,
};
- const dbSnapshotStorage: InstanceAiTracingSnapshotStorage = {
+ const eventLog: InstanceAiTracingEventLog = {
findLangsmithAnchor: vi.fn(async () => undefined),
- ...overrides.dbSnapshotStorage,
+ ...overrides.eventLog,
};
const aiService: InstanceAiTracingAiService = {
isProxyEnabled: vi.fn(() => false),
@@ -81,11 +81,11 @@ function createService(
logger,
eventReader,
runState,
- dbSnapshotStorage,
+ eventLog,
aiService,
});
- return { service, logger, eventReader, runState, dbSnapshotStorage, aiService };
+ return { service, logger, eventReader, runState, eventLog, aiService };
}
describe('InstanceAiTracingService', () => {
@@ -313,7 +313,7 @@ describe('InstanceAiTracingService', () => {
describe('submitLangsmithFeedback', () => {
it('skips submission when no LangSmith anchor exists', async () => {
const findLangsmithAnchor = vi.fn(async () => undefined);
- const { service } = createService({ dbSnapshotStorage: { findLangsmithAnchor } });
+ const { service } = createService({ eventLog: { findLangsmithAnchor } });
await service.submitLangsmithFeedback(
{ id: 'user-1' } as unknown as User,
@@ -332,7 +332,7 @@ describe('InstanceAiTracingService', () => {
langsmithTraceId: 'ls-trace',
}));
const { service } = createService({
- dbSnapshotStorage: { findLangsmithAnchor },
+ eventLog: { findLangsmithAnchor },
aiService: { isProxyEnabled: vi.fn(() => false) },
});
diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts
index 366d30939de..a5351af1daf 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts
@@ -424,79 +424,7 @@ describe('InstanceAiController', () => {
expect(scopeOf('events')).toEqual({ scope: 'instanceAi:message', globalOnly: true });
});
- it('should bootstrap run-sync from the richer persisted snapshot when live events are incomplete', async () => {
- memoryService.checkThreadOwnership.mockResolvedValue('owned');
- eventLog.getEventsAfter.mockResolvedValue([]);
- instanceAiService.getThreadStatus.mockReturnValue({
- hasActiveRun: true,
- isSuspended: false,
- backgroundTasks: [],
- } as never);
- instanceAiService.getMessageGroupId.mockReturnValue('mg-1');
- instanceAiService.getRunIdsForMessageGroup.mockReturnValue(['run-1']);
- eventLog.getEventsForRuns.mockResolvedValue([
- {
- type: 'run-start',
- runId: 'run-1',
- agentId: 'agent-root',
- payload: { messageId: 'msg-1', messageGroupId: 'mg-1' },
- },
- ] as never);
- memoryService.getLatestRunSnapshot.mockResolvedValue({
- runId: 'run-1',
- messageGroupId: 'mg-1',
- runIds: ['run-1'],
- tree: {
- agentId: 'agent-root',
- role: 'orchestrator',
- status: 'active',
- textContent: '',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- planItems: [
- {
- id: 'task-1',
- title: 'Build workflow',
- kind: 'build-workflow',
- spec: 'Create the workflow',
- deps: [],
- },
- ],
- },
- });
-
- const sseRes = mock void }>({
- setHeader: vi.fn(),
- flushHeaders: vi.fn(),
- write: vi.fn(),
- end: vi.fn(),
- flush: vi.fn(),
- });
- eventBus.subscribe.mockReturnValue(vi.fn());
-
- const sseReq = mock({
- user: { id: USER_ID },
- headers: {},
- once: vi.fn(),
- });
-
- await controller.events(sseReq, sseRes, THREAD_ID, { lastEventId: undefined } as never);
-
- expect(instanceAiService.replayUndeliveredTerminalOutcomes).toHaveBeenCalledWith(THREAD_ID, {
- delivery: 'event',
- });
-
- const runSyncFrame = (sseRes.write as Mock).mock.calls
- .map(([frame]) => String(frame))
- .find((frame) => frame.startsWith('event: run-sync'));
-
- expect(runSyncFrame).toContain('"agent-root"');
- expect(runSyncFrame).toContain('"planItems"');
- });
-
- it('should replay events that arrive while bootstrap snapshot fetches are in flight', async () => {
+ it('should replay events that arrive while bootstrap log reads are in flight', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
@@ -505,7 +433,6 @@ describe('InstanceAiController', () => {
} as never);
instanceAiService.getMessageGroupId.mockReturnValue('mg-1');
instanceAiService.getRunIdsForMessageGroup.mockReturnValue(['run-1']);
- eventLog.getEventsForRuns.mockResolvedValue([]);
eventLog.getEventsAfter.mockResolvedValue([]);
let subscribeHandler: ((stored: { id: number; event: unknown }) => void) | undefined;
@@ -516,17 +443,17 @@ describe('InstanceAiController', () => {
return vi.fn();
});
- // While the persisted snapshot is being fetched, a relayed event arrives:
+ // While the run-sync tree read is in flight, a relayed event arrives:
// the early subscription keeps it flowing into the store, and the replay
// after the await must pick it up exactly once.
const midAwaitEvent = {
id: 7,
event: { type: 'run-finish', runId: 'run-1', agentId: 'a1', payload: {} },
};
- memoryService.getLatestRunSnapshot.mockImplementation(async () => {
+ eventLog.getEventsForRuns.mockImplementation(async () => {
subscribeHandler!(midAwaitEvent);
eventLog.getEventsAfter.mockResolvedValue([midAwaitEvent] as never);
- return undefined;
+ return [];
});
const sseRes = mock void }>({
@@ -547,7 +474,7 @@ describe('InstanceAiController', () => {
// Subscription must be registered before the async bootstrap starts, so
// sibling mains keep relaying events for this thread during the awaits.
expect(eventBus.subscribe.mock.invocationCallOrder[0]).toBeLessThan(
- memoryService.getLatestRunSnapshot.mock.invocationCallOrder[0],
+ (eventLog.getEventsAfter as Mock).mock.invocationCallOrder[0],
);
const eventFrames = (sseRes.write as Mock).mock.calls
@@ -566,12 +493,13 @@ describe('InstanceAiController', () => {
instanceAiService.getMessageGroupId.mockReturnValue('mg-1');
instanceAiService.getRunIdsForMessageGroup.mockReturnValue(['run-1']);
eventLog.getEventsForRuns.mockResolvedValue([]);
- eventLog.getEventsAfter.mockResolvedValue([
- { id: 1, event: { type: 'text-delta', runId: 'run-1', agentId: 'a1', payload: {} } },
- ] as never);
- const unsubscribe = vi.fn();
- eventBus.subscribe.mockReturnValue(unsubscribe);
+ const unsubscribers: Array> = [];
+ eventBus.subscribe.mockImplementation(() => {
+ const unsubscribe = vi.fn();
+ unsubscribers.push(unsubscribe);
+ return unsubscribe;
+ });
let closeHandler: (() => void) | undefined;
const sseReq = mock({
@@ -589,15 +517,21 @@ describe('InstanceAiController', () => {
flush: vi.fn(),
});
- // The client disconnects while the persisted snapshot is being fetched.
- memoryService.getLatestRunSnapshot.mockImplementation(async () => {
+ // The client disconnects while the replay read is in flight.
+ eventLog.getEventsAfter.mockImplementation(async () => {
closeHandler!();
- return undefined;
+ return [
+ { id: 1, event: { type: 'text-delta', runId: 'run-1', agentId: 'a1', payload: {} } },
+ ] as never;
});
await controller.events(sseReq, sseRes, THREAD_ID, { lastEventId: undefined } as never);
- expect(unsubscribe).toHaveBeenCalledTimes(1);
+ // Both the live subscription (via the close handler) and the temporary
+ // buffering subscription (via the bootstrap finally) are removed.
+ expect(unsubscribers).toHaveLength(2);
+ expect(unsubscribers[0]).toHaveBeenCalledTimes(1);
+ expect(unsubscribers[1]).toHaveBeenCalledTimes(1);
expect(sseRes.write).not.toHaveBeenCalled();
});
@@ -2126,7 +2060,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('delivers events that land during the run-sync tree reads', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
- memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -2192,7 +2125,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('serves the open streamed segment as one ephemeral delta frame and skips its buffered deltas', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
- memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -2427,7 +2359,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('does not re-apply a gap block already folded into a delivered run-sync tree', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
- memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -2536,7 +2467,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('stops the bootstrap when the client disconnects during a durable read', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
- memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts
index 335645fd9c6..b78bd2522ef 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts
@@ -185,13 +185,11 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
type FinalizeInternals = {
publishRunFinish: ReturnType;
emitRunMetrics: ReturnType;
- saveAgentTreeSnapshot: ReturnType;
refineTitleIfNeeded: ReturnType;
finalizeRun: (
threadId: string,
runId: string,
status: 'completed' | 'cancelled' | 'errored',
- snapshotStorage: unknown,
options?: { userId?: string; modelId?: ModelConfig },
) => Promise;
};
@@ -202,7 +200,6 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
const service = Object.create(InstanceAiService.prototype) as unknown as FinalizeInternals;
service.publishRunFinish = vi.fn();
service.emitRunMetrics = vi.fn();
- service.saveAgentTreeSnapshot = vi.fn(async () => {});
service.refineTitleIfNeeded = vi.fn(async () => {});
return service;
}
@@ -210,7 +207,7 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
it('refines the title when a completed run supplies userId and modelId', async () => {
const service = createService();
- await service.finalizeRun('thread-1', 'run-1', 'completed', {}, { userId: 'user-1', modelId });
+ await service.finalizeRun('thread-1', 'run-1', 'completed', { userId: 'user-1', modelId });
expect(service.refineTitleIfNeeded).toHaveBeenCalledWith('thread-1', 'user-1', modelId);
});
@@ -218,7 +215,7 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
it('skips refinement when modelId is missing', async () => {
const service = createService();
- await service.finalizeRun('thread-1', 'run-1', 'completed', {}, { userId: 'user-1' });
+ await service.finalizeRun('thread-1', 'run-1', 'completed', { userId: 'user-1' });
expect(service.refineTitleIfNeeded).not.toHaveBeenCalled();
});
diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts
index 7b50d9fce25..af26ca8ee4d 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts
@@ -201,13 +201,12 @@ vi.mock('@/permissions.ee/check-access', () => ({
}));
import type { MemoryTaskUsageReport, ScopedMemoryTaskEvent } from '@n8n/agents';
-import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
+import type { InstanceAiEvent } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import type { InstanceAiConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import {
- buildAgentTreeFromEvents,
createAllTools,
createLazyRuntimeWorkspace,
createLazyWorkspaceRuntimeSkillSource,
@@ -265,7 +264,6 @@ type BackgroundTaskFollowUpServiceInternals = {
spawnBackgroundTask: (
runId: string,
opts: SpawnBackgroundTaskOptions,
- snapshotStorage: unknown,
messageGroupIdOverride?: string,
) => SpawnBackgroundTaskResult;
backgroundTasks: {
@@ -304,15 +302,6 @@ type BackgroundTaskFollowUpServiceInternals = {
terminalOutcome: {
recordBackgroundTerminalOutcome: MockedFunction<(task: ManagedBackgroundTask) => Promise>;
};
- saveAgentTreeSnapshot: MockedFunction<
- (
- threadId: string,
- runId: string,
- snapshotStorage: unknown,
- isUpdate?: boolean,
- overrideMessageGroupId?: string,
- ) => Promise
- >;
startInternalFollowUpRun: MockedFunction<
(
user: User,
@@ -401,15 +390,6 @@ function createBackgroundTaskFollowUpService({
service.terminalOutcome = {
recordBackgroundTerminalOutcome: vi.fn(async (_task: ManagedBackgroundTask) => {}),
};
- service.saveAgentTreeSnapshot = vi.fn(
- async (
- _threadId: string,
- _runId: string,
- _snapshotStorage: unknown,
- _isUpdate?: boolean,
- _overrideMessageGroupId?: string,
- ) => {},
- );
service.startInternalFollowUpRun = vi.fn(
async (
_user: User,
@@ -718,7 +698,6 @@ type TerminalGuardOrderServiceInternals = {
status: 'completed' | 'cancelled' | 'errored',
reason?: string,
) => void;
- saveAgentTreeSnapshot: Mock;
backgroundTasks: { getRunningTasks: Mock; getRunningTasksByParentCheckpoint?: Mock };
temporaryWorkflowService: { reapForRun: Mock };
creditService: { claimRunUsage: Mock; ensureQuotaLockApplied: Mock };
@@ -742,7 +721,6 @@ type TerminalGuardOrderServiceInternals = {
toolCallId: string;
signal: AbortSignal;
abortController: AbortController;
- snapshotStorage: unknown;
tracing?: InstanceAiTraceContext;
orchestrationContext?: { tracing?: unknown };
checkpoint?: { isCheckpointFollowUp: boolean; checkpointTaskId: string };
@@ -754,31 +732,6 @@ type TerminalGuardOrderServiceInternals = {
) => Promise;
};
-type SnapshotServiceInternals = {
- saveAgentTreeSnapshot: (
- threadId: string,
- runId: string,
- snapshotStorage: {
- getLatest: Mock;
- save: Mock;
- updateLast: Mock;
- },
- isUpdate?: boolean,
- overrideMessageGroupId?: string,
- ) => Promise;
- runState: {
- getMessageGroupId: Mock;
- getRunIdsForMessageGroup: Mock;
- };
- eventBus: {
- getEventsForRun: Mock;
- getEventsForRuns: Mock;
- };
- eventLog: { flush: Mock; getEventsForRuns: Mock };
- tracing: { getTraceContext: Mock };
- logger: { warn: Mock };
-};
-
function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
const events: InstanceAiEvent[] = [];
const service = Object.create(
@@ -821,7 +774,6 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
};
service.threadPushRef = new Map();
service.pendingBrowserCredentialSetups = new Map();
- service.saveAgentTreeSnapshot = vi.fn(async () => {});
service.backgroundTasks = { getRunningTasks: vi.fn(() => []) };
service.temporaryWorkflowService = { reapForRun: vi.fn(async () => []) };
service.creditService = {
@@ -835,7 +787,6 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
service.terminalOutcome = new InstanceAiTerminalOutcomeService({
eventBus: service.eventBus,
- dbSnapshotStorage: {},
agentMemory: {},
telemetry: service.telemetry,
errorReporter: service.instanceAiErrorReporter,
@@ -855,42 +806,10 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
payload: { status: status === 'errored' ? 'error' : status },
} as InstanceAiEvent);
},
- saveAgentTreeSnapshot: async (threadId: string, runId: string, snapshotStorage: unknown) => {
- await service.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
- },
} as unknown as InstanceAiTerminalOutcomeServiceOptions);
return service;
}
-function createSnapshotService(): SnapshotServiceInternals {
- const service = Object.create(InstanceAiService.prototype) as unknown as SnapshotServiceInternals;
- service.runState = {
- getMessageGroupId: vi.fn(() => undefined),
- getRunIdsForMessageGroup: vi.fn(() => []),
- };
- service.eventBus = {
- getEventsForRun: vi.fn(() => []),
- getEventsForRuns: vi.fn(() => []),
- };
- service.eventLog = { flush: vi.fn(async () => {}), getEventsForRuns: vi.fn(async () => []) };
- service.tracing = { getTraceContext: vi.fn(() => undefined) };
- service.logger = { warn: vi.fn() };
- return service;
-}
-
-function makeAgentTree(): InstanceAiAgentNode {
- return {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'completed',
- textContent: 'Initial response',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [{ type: 'text', content: 'Initial response' }],
- };
-}
-
describe('InstanceAiService — runtime workspace setup', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -923,7 +842,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
abortSignal: AbortSignal,
) => Promise<{
orchestrationContext: {
- outputRedaction?: unknown;
workspace?: unknown;
runtimeSkills?: {
registry: { skillsHash: string; skills: Array<{ id: string }> };
@@ -955,7 +873,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
ensureThreadExists: Mock;
agentMemory: unknown;
dbIterationLogStorage: unknown;
- dbSnapshotStorage: unknown;
checkpointStore: unknown;
instanceAiConfig: Record;
defaultTimeZone: string;
@@ -1006,7 +923,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
service.ensureThreadExists = vi.fn(async () => {});
service.agentMemory = { getThreadProjectId: vi.fn(async () => 'project-1') };
service.dbIterationLogStorage = {};
- service.dbSnapshotStorage = {};
service.checkpointStore = {};
service.instanceAiConfig = {};
service.defaultTimeZone = 'UTC';
@@ -1066,10 +982,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
new AbortController().signal,
);
- // OutputRedactor treats an OMITTED policy as ENABLED (`options !== false`),
- // so this must stay an explicit false or every stream is scanned and the
- // durable log stores redacted text instead of raw (INS-837).
- expect(environment.orchestrationContext.outputRedaction).toBe(false);
expect(createLazyRuntimeWorkspace).toHaveBeenCalledTimes(2);
expect(createLazyRuntimeWorkspace).toHaveBeenNthCalledWith(
2,
@@ -1298,7 +1210,6 @@ describe('InstanceAiService — background task auto-follow-up', () => {
role: 'workflow-builder',
run: async () => 'done',
},
- {},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
@@ -1326,7 +1237,6 @@ describe('InstanceAiService — background task auto-follow-up', () => {
workItemId: 'wi-1',
run: async () => 'done',
},
- {},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
@@ -1349,20 +1259,12 @@ describe('InstanceAiService — background task auto-follow-up', () => {
role: 'workflow-builder',
run: async () => 'done',
},
- {},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
expect(service.startInternalFollowUpRun).not.toHaveBeenCalled();
expect(service.terminalOutcome.recordBackgroundTerminalOutcome).toHaveBeenCalledWith(task);
- expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith(
- 'thread-a',
- 'run-1',
- {},
- true,
- 'group-1',
- );
});
it('skips internal follow-up when the task itself timed out', async () => {
@@ -1380,7 +1282,6 @@ describe('InstanceAiService — background task auto-follow-up', () => {
role: 'workflow-builder',
run: async () => 'done',
},
- {},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
@@ -1934,7 +1835,6 @@ type SuspendedRunResumeServiceInternals = {
};
emitTerminalRun: Mock;
logger: { warn: Mock; debug: Mock };
- dbSnapshotStorage: unknown;
tracing: { createOrchestratorResumeTraceContext: Mock; finalizeDetachedTraceRun: Mock };
memoryService: { getThreadMetadata: Mock };
processResumedStream: Mock;
@@ -1978,7 +1878,6 @@ function createSuspendedRunResumeService(): SuspendedRunResumeServiceInternals {
service.emitTerminalRun = vi.fn(async () => {});
service.logger = { warn: vi.fn(), debug: vi.fn() };
service.memoryService = { getThreadMetadata: vi.fn(async () => undefined) };
- service.dbSnapshotStorage = {};
service.tracing = {
createOrchestratorResumeTraceContext: vi.fn(async () => undefined),
finalizeDetachedTraceRun: vi.fn(async () => {}),
@@ -2951,139 +2850,6 @@ describe('InstanceAiService — rebuildAgentForResume', () => {
});
});
-describe('InstanceAiService — agent tree snapshots', () => {
- beforeEach(() => {
- (buildAgentTreeFromEvents as Mock).mockImplementation(
- (events: Array<{ type: string; payload?: { text?: string } }>) => ({
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'completed',
- textContent: events
- .map((event) => (event.type === 'text-delta' ? (event.payload?.text ?? '') : ''))
- .join(''),
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- }),
- );
- });
-
- it('falls back to persisted run ids when an old background group mapping was pruned', async () => {
- const service = createSnapshotService();
- const terminalEvent: InstanceAiEvent = {
- type: 'text-delta',
- runId: 'run-background',
- agentId: 'agent-001',
- payload: { text: 'background finished' },
- };
- const snapshotStorage = {
- getLatest: vi.fn(async () => ({
- tree: makeAgentTree(),
- runId: 'run-original',
- messageGroupId: 'group-old',
- runIds: ['run-original', 'run-background'],
- })),
- save: vi.fn(async () => {}),
- updateLast: vi.fn(async () => {}),
- };
- service.eventLog.getEventsForRuns.mockResolvedValue([terminalEvent]);
-
- await service.saveAgentTreeSnapshot(
- 'thread-a',
- 'run-background',
- snapshotStorage,
- true,
- 'group-old',
- );
-
- expect(service.runState.getRunIdsForMessageGroup).toHaveBeenCalledWith('group-old');
- expect(snapshotStorage.getLatest).toHaveBeenCalledWith('thread-a', {
- messageGroupId: 'group-old',
- runId: 'run-background',
- });
- expect(service.eventLog.getEventsForRuns).toHaveBeenCalledWith('thread-a', [
- 'run-original',
- 'run-background',
- ]);
- expect(snapshotStorage.updateLast).toHaveBeenCalledWith(
- 'thread-a',
- expect.objectContaining({ textContent: 'background finished' }),
- 'run-background',
- expect.objectContaining({
- messageGroupId: 'group-old',
- runIds: ['run-original', 'run-background'],
- }),
- );
- expect(snapshotStorage.save).not.toHaveBeenCalled();
- });
-
- it('skips update snapshots when no events are available for a pruned group', async () => {
- const service = createSnapshotService();
- const snapshotStorage = {
- getLatest: vi.fn(async () => ({
- tree: makeAgentTree(),
- runId: 'run-original',
- messageGroupId: 'group-old',
- runIds: ['run-background'],
- })),
- save: vi.fn(async () => {}),
- updateLast: vi.fn(async () => {}),
- };
-
- await service.saveAgentTreeSnapshot(
- 'thread-a',
- 'run-background',
- snapshotStorage,
- true,
- 'group-old',
- );
-
- expect(snapshotStorage.updateLast).not.toHaveBeenCalled();
- expect(snapshotStorage.save).not.toHaveBeenCalled();
- expect(service.logger.warn).toHaveBeenCalledWith(
- 'Skipped updating empty Instance AI agent tree snapshot',
- expect.objectContaining({
- threadId: 'thread-a',
- runId: 'run-background',
- messageGroupId: 'group-old',
- }),
- );
- });
-
- it('reads snapshot input from the durable log', async () => {
- const service = createSnapshotService();
- const logEvent: InstanceAiEvent = {
- type: 'text-delta',
- runId: 'run-1',
- agentId: 'agent-001',
- payload: { text: 'from the log' },
- };
- service.eventLog.getEventsForRuns.mockResolvedValue([logEvent]);
- const snapshotStorage = {
- getLatest: vi.fn(async () => undefined),
- save: vi.fn(async () => {}),
- updateLast: vi.fn(async () => {}),
- };
-
- await service.saveAgentTreeSnapshot('thread-a', 'run-1', snapshotStorage);
-
- expect(service.eventLog.getEventsForRuns).toHaveBeenCalledWith('thread-a', ['run-1']);
- // Read-own-writes barrier: the drain settles before the snapshot input is
- // read, so a just-published terminal fact can't be missing from the tree.
- expect(service.eventLog.flush).toHaveBeenCalledWith('thread-a');
- expect(service.eventLog.flush.mock.invocationCallOrder[0]).toBeLessThan(
- service.eventLog.getEventsForRuns.mock.invocationCallOrder[0],
- );
- expect(snapshotStorage.save).toHaveBeenCalledWith(
- 'thread-a',
- expect.objectContaining({ textContent: 'from the log' }),
- 'run-1',
- expect.any(Object),
- );
- });
-});
-
describe('InstanceAiService — terminal response guard wiring', () => {
beforeEach(() => {
vi.mocked(resumeAgentRun).mockReset();
@@ -3109,12 +2875,10 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
expect(service.eventBus.events.map((event) => event.type)).toEqual(['error', 'run-finish']);
- expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
// Thrown run-loop errors must reach telemetry too, not just the SSE stream
expect(service.telemetry.track).toHaveBeenCalledWith('instance_ai_run_finished', {
thread_id: 'thread-a',
@@ -3150,7 +2914,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
resumeExecutionToken,
},
);
@@ -3184,7 +2947,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
resumeExecutionToken,
},
);
@@ -3216,7 +2978,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3253,7 +3014,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3286,7 +3046,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3356,7 +3115,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3443,7 +3201,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3485,7 +3242,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3553,7 +3309,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
},
);
@@ -3603,7 +3358,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
tracing,
},
);
@@ -3647,7 +3401,7 @@ describe('InstanceAiService — terminal response guard wiring', () => {
const segmentBTracing = {
actorRun: { id: 'segment-b-actor' },
} as unknown as InstanceAiTraceContext;
- service.saveAgentTreeSnapshot.mockImplementationOnce(async () => {
+ service.tracing.finalizeRunTracing.mockImplementationOnce(async () => {
// The next approval can register its trace immediately after the
// confirmation is published, before this segment reaches finally.
service.tracing.registerTraceContext('run-1', 'thread-a', segmentBTracing, 'group-1');
@@ -3676,7 +3430,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
tracing: segmentATracing,
},
);
@@ -3722,7 +3475,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
};
// Segment A: the resumed run suspends again on HITL.
@@ -3808,7 +3560,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
};
// Segment A: the resumed run suspends again on HITL.
@@ -3895,7 +3646,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
tracing,
},
);
@@ -4070,7 +3820,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
- snapshotStorage: {},
resumeExecutionToken: Symbol('resume-execution'),
});
@@ -4150,7 +3899,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
expect(service.eventBus.events).toEqual([]);
expect(service.tracing.finalizeRunTracing).not.toHaveBeenCalled();
expect(service.tracing.maybeFinalizeRunTraceRoot).not.toHaveBeenCalled();
- expect(service.saveAgentTreeSnapshot).not.toHaveBeenCalled();
expect(service.temporaryWorkflowService.reapForRun).not.toHaveBeenCalled();
expect(service.finalizeRun).not.toHaveBeenCalled();
expect(service.creditService.claimRunUsage).not.toHaveBeenCalled();
@@ -4219,7 +3967,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
errorCode: undefined,
});
expect(service.eventBus.events.map((event) => event.type)).toEqual(['error', 'run-finish']);
- expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
expect(service.tracing.finalizeRunTracing).not.toHaveBeenCalled();
expect(service.tracing.finalizeMessageTraceRoot).not.toHaveBeenCalled();
expect(service.schedulePlannedTasks).not.toHaveBeenCalled();
@@ -4260,7 +4007,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
);
expect(terminalResponse).not.toHaveBeenCalled();
expect(service.eventBus.events).toEqual([]);
- expect(service.saveAgentTreeSnapshot).not.toHaveBeenCalled();
});
it('surfaces an error to the user when a resume throws before claiming its checkpoint', async () => {
@@ -4310,7 +4056,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
error_source: 'exception',
user_id: 'user-1',
});
- expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
expect(service.runState.clearActiveRun).toHaveBeenCalledWith(
'thread-a',
opts.resumeExecutionToken,
@@ -4329,12 +4074,11 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
expect(service.eventBus.events.map((event) => event.type)).toEqual(['error', 'run-finish']);
});
- it('still finishes a failed resume when the guard read and the snapshot save fail', async () => {
+ it('still finishes a failed resume when the guard read fails', async () => {
const service = createTerminalGuardOrderService();
const abortController = new AbortController();
const opts = { ...resumedStreamOpts(abortController), messageGroupId: 'group-1' };
service.eventBus.getEventsForRuns.mockRejectedValue(new Error('event log unavailable'));
- service.saveAgentTreeSnapshot.mockRejectedValue(new Error('snapshot write failed'));
vi.mocked(resumeAgentRun).mockRejectedValueOnce(new Error('Invalid resume payload'));
await service.processResumedStream({}, {}, opts);
@@ -4344,10 +4088,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
'Failed to evaluate the terminal response for a settling run',
expect.objectContaining({ error: 'event log unavailable' }),
);
- expect(service.logger.warn).toHaveBeenCalledWith(
- 'Failed to save the agent tree snapshot for a settling run',
- expect.objectContaining({ error: 'snapshot write failed' }),
- );
});
it('cancels the run when a resume is aborted before claiming its checkpoint', async () => {
@@ -4383,7 +4123,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
},
}),
]);
- expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
});
it('reports the run timeout reason when a resume times out before claiming', async () => {
@@ -4404,7 +4143,7 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
]);
});
- it('leaves a preserved HITL snapshot alone when a resume is aborted before claiming', async () => {
+ it('leaves a preserved HITL run alone when a resume is aborted before claiming', async () => {
const service = createTerminalGuardOrderService();
const abortController = new AbortController();
const opts = { ...resumedStreamOpts(abortController), messageGroupId: 'group-1' };
@@ -4415,7 +4154,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
await service.processResumedStream({}, {}, opts);
expect(service.eventBus.events).toEqual([]);
- expect(service.saveAgentTreeSnapshot).not.toHaveBeenCalled();
});
it('terminalizes a same-name error that occurs after the resume was claimed', async () => {
@@ -4442,7 +4180,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
'thread-a',
'run-1',
'errored',
- {},
expect.any(Object),
);
expect(service.instanceAiErrorReporter.endRun).toHaveBeenCalledWith(
@@ -4732,7 +4469,6 @@ describe('InstanceAiService — planned task settlement', () => {
syncPlannedTasksToUi: Mock;
eventBus: { publish: Mock };
createPlannedTaskState: Mock;
- saveAgentTreeSnapshot: Mock;
cancelAwaitingApprovalPlan: Mock;
backgroundTasks: {
cancelThread: Mock;
@@ -4766,7 +4502,6 @@ describe('InstanceAiService — planned task settlement', () => {
createPlannedTaskState: vi.fn(async () => ({ plannedTaskService })),
syncPlannedTasksToUi: vi.fn(async () => {}),
schedulePlannedTasks: vi.fn(async () => {}),
- saveAgentTreeSnapshot: vi.fn(async () => {}),
cancelAwaitingApprovalPlan: vi.fn(async () => {}),
backgroundTasks: {
cancelThread: vi.fn(() => [task]),
diff --git a/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts b/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts
index 9460c089745..f3019d284ec 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts
@@ -92,156 +92,8 @@ describe('parseStoredMessages', () => {
expect(assistant.content).toBe('Hello! How can I help?');
expect(assistant.reasoning).toBe('');
expect(assistant.isStreaming).toBe(false);
- expect(assistant.agentTree).toBeDefined();
- expect(assistant.agentTree?.textContent).toBe('Hello! How can I help?');
- });
-
- it('should parse assistant message with tool invocations (result state)', () => {
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'List workflows',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- { type: 'text', text: 'Here are your workflows' },
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'list-workflows',
- input: { limit: 10 },
- state: 'resolved',
- output: { workflows: ['wf1'] },
- },
- ],
- createdAt: makeDate(1),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- const assistant = result[1];
- expect(assistant.agentTree?.toolCalls).toHaveLength(1);
- expect(assistant.agentTree?.toolCalls[0]).toMatchObject({
- toolCallId: 'tc-1',
- toolName: 'list-workflows',
- args: { limit: 10 },
- result: { workflows: ['wf1'] },
- isLoading: false,
- renderHint: 'default',
- });
- });
-
- it('should parse assistant message with tool invocations (call state - interrupted)', () => {
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Do something',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-2',
- toolName: 'task-control',
- input: { tasks: [] },
- },
- ],
- createdAt: makeDate(1),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- const tc = result[1].agentTree?.toolCalls[0];
- expect(tc?.isLoading).toBe(true);
- expect(tc?.result).toBeUndefined();
- expect(tc?.renderHint).toBe('tasks');
- });
-
- it('should surface rejected tool calls via `error`, not `result`', () => {
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Do something',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-rej',
- toolName: 'workflows',
- input: { name: 'x' },
- state: 'rejected',
- error: 'Workflow not found',
- },
- ],
- createdAt: makeDate(1),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- const tc = result[1].agentTree?.toolCalls[0];
- expect(tc?.isLoading).toBe(false);
- expect(tc?.result).toBeUndefined();
- expect(tc?.error).toBe('Workflow not found');
- });
-
- it('should skip malformed tool-call parts instead of rendering half-populated cards', () => {
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Go',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- // Valid tool call — should survive.
- {
- type: 'tool-call',
- toolCallId: 'tc-ok',
- toolName: 'list-workflows',
- input: {},
- state: 'resolved',
- output: { ok: true },
- },
- // Missing toolName — fails the schema, must be dropped.
- { type: 'tool-call', toolCallId: 'tc-no-name', input: {}, state: 'resolved' },
- // Missing toolCallId — dropped.
- { type: 'tool-call', toolName: 'orphan', input: {}, state: 'resolved' },
- // `error` wrong type for a rejected call — dropped.
- {
- type: 'tool-call',
- toolCallId: 'tc-bad-error',
- toolName: 'workflows',
- state: 'rejected',
- error: { not: 'a string' },
- },
- ],
- createdAt: makeDate(1),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- const toolCalls = result[1].agentTree?.toolCalls ?? [];
- expect(toolCalls.map((tc) => tc.toolCallId)).toEqual(['tc-ok']);
+ // No snapshot → the row renders from its own content, without a tree.
+ expect(assistant.agentTree).toBeUndefined();
});
it('should drop content parts with an unrecognized type', () => {
@@ -267,9 +119,7 @@ describe('parseStoredMessages', () => {
const result = parseStoredMessages(messages);
expect(result[1].content).toBe('Hello');
- expect(result[1].agentTree?.timeline).toEqual([
- { type: 'text', content: 'Hello', responseId: 'msg-a' },
- ]);
+ expect(result[1].agentTree).toBeUndefined();
});
it('should parse reasoning from native parts', () => {
@@ -295,14 +145,10 @@ describe('parseStoredMessages', () => {
expect(result[1].reasoning).toBe('Reasoning part');
expect(result[1].content).toBe('Answer');
- // Reasoning keeps its chronological slot in the timeline
- expect(result[1].agentTree?.timeline).toEqual([
- { type: 'reasoning', content: 'Reasoning part', responseId: 'msg-a' },
- { type: 'text', content: 'Answer', responseId: 'msg-a' },
- ]);
+ expect(result[1].agentTree).toBeUndefined();
});
- it('should build agentTree for reasoning-only assistant messages', () => {
+ it('should parse reasoning-only assistant messages without a tree', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
@@ -322,104 +168,7 @@ describe('parseStoredMessages', () => {
expect(result[1].reasoning).toBe('Just reasoning');
expect(result[1].content).toBe('');
- expect(result[1].agentTree?.timeline).toEqual([
- { type: 'reasoning', content: 'Just reasoning', responseId: 'msg-a' },
- ]);
- });
-
- it('should bracket reconstructed tool calls with adjacent message timestamps', () => {
- // Snapshot-less reloads have no real per-call timestamps; the interval
- // between stored rows approximates them so thinking blocks can still
- // derive a "Thought for Xs" duration.
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Build it',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a1',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-done',
- toolName: 'search-nodes',
- input: {},
- state: 'resolved',
- output: {},
- },
- {
- type: 'tool-call',
- toolCallId: 'tc-pending',
- toolName: 'build-workflow',
- input: {},
- state: 'pending',
- },
- ],
- createdAt: makeDate(12_000),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- const toolCalls = result[1].agentTree?.toolCalls ?? [];
- expect(toolCalls[0]).toMatchObject({
- toolCallId: 'tc-done',
- startedAt: makeDate().toISOString(),
- completedAt: makeDate(12_000).toISOString(),
- });
- // An unresolved call gets no completedAt — it never finished.
- expect(toolCalls[1].startedAt).toBe(makeDate().toISOString());
- expect(toolCalls[1].completedAt).toBeUndefined();
- });
-
- it('should group each assistant row under its own synthetic responseId', () => {
- // One stored assistant row = one LLM response. The synthetic per-row
- // responseId lets the frontend fold narration (text followed by trace
- // content in the same response) into thinking blocks after a reload
- // where no snapshot survived.
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Build it',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a1',
- role: 'assistant',
- content: [
- { type: 'text', text: 'Checking the nodes first.' },
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'search-nodes',
- input: {},
- state: 'resolved',
- output: {},
- },
- ],
- createdAt: makeDate(1),
- },
- {
- id: 'msg-a2',
- role: 'assistant',
- content: [{ type: 'text', text: 'Done — here is the workflow.' }],
- createdAt: makeDate(2),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- expect(result[1].agentTree?.timeline).toEqual([
- { type: 'text', content: 'Checking the nodes first.', responseId: 'msg-a1' },
- { type: 'tool-call', toolCallId: 'tc-1', responseId: 'msg-a1' },
- ]);
- expect(result[2].agentTree?.timeline).toEqual([
- { type: 'text', content: 'Done — here is the workflow.', responseId: 'msg-a2' },
- ]);
+ expect(result[1].agentTree).toBeUndefined();
});
it('should normalize legacy aggregate reasoning into the timeline on reload', () => {
@@ -519,64 +268,6 @@ describe('parseStoredMessages', () => {
expect(result[1].runId).toBe('run_abc123');
});
- it('should render the message-derived tree when the paired snapshot is an empty cancelled tree', () => {
- const messages: StoredAgentMessage[] = [
- { id: 'msg-u', role: 'user', content: 'Build something', createdAt: makeDate() },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- { type: 'text', text: 'Working on it' },
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'gmail',
- input: { q: 'invoices' },
- state: 'resolved',
- output: { count: 3 },
- },
- ],
- createdAt: makeDate(1),
- },
- ];
- // Snapshot saved at cancel time (after the message): an empty `cancelled` tree
- // from a run whose events were lost before the snapshot was built.
- const cancelledTree: InstanceAiAgentNode = {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'cancelled',
- cancellationReason: 'user',
- textContent: '',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- };
- const snapshots = [
- {
- tree: cancelledTree,
- runId: 'run_x',
- messageGroupId: 'mg_x',
- createdAt: makeDate(5),
- updatedAt: makeDate(5),
- },
- ];
-
- const result = parseStoredMessages(messages, snapshots);
-
- const assistant = result.find((m) => m.role === 'assistant');
- // The empty snapshot is discarded in favour of the message content.
- expect(assistant?.agentTree?.toolCalls).toHaveLength(1);
- expect(assistant?.agentTree?.toolCalls[0].toolName).toBe('gmail');
- expect(assistant?.agentTree?.textContent).toBe('Working on it');
- // The cancelled status + cause are inherited from the snapshot so the UI can flag it.
- expect(assistant?.agentTree?.status).toBe('cancelled');
- expect(assistant?.agentTree?.cancellationReason).toBe('user');
- // Snapshot grouping metadata is still attached.
- expect(assistant?.messageGroupId).toBe('mg_x');
- expect(assistant?.runId).toBe('run_x');
- });
-
it('should keep a task-list-only snapshot instead of discarding it as empty', () => {
const messages: StoredAgentMessage[] = [
{ id: 'msg-u', role: 'user', content: 'plan it', createdAt: makeDate() },
@@ -617,267 +308,14 @@ describe('parseStoredMessages', () => {
expect(assistant?.agentTree?.tasks?.tasks).toHaveLength(1);
});
- it('should normalize a non-terminal (running) snapshot status to completed', () => {
- const messages: StoredAgentMessage[] = [
- { id: 'msg-u', role: 'user', content: 'do it', createdAt: makeDate() },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [{ type: 'text', text: 'Partial work' }],
- createdAt: makeDate(1),
- },
- ];
- // A mid-run snapshot (e.g. persisted while active) that is otherwise empty. A
- // reconstructed historical turn has no live stream, so it must not stay "busy".
- const runningTree: InstanceAiAgentNode = {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'active',
- textContent: '',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- };
- const snapshots = [
- {
- tree: runningTree,
- runId: 'run_x',
- messageGroupId: 'mg_x',
- createdAt: makeDate(2),
- updatedAt: makeDate(2),
- },
- ];
-
- const result = parseStoredMessages(messages, snapshots);
-
- const assistant = result.find((m) => m.role === 'assistant');
- expect(assistant?.agentTree?.status).toBe('completed');
- });
-
- it('should settle a pending tool call when reconstructing a cancelled turn', () => {
- const messages: StoredAgentMessage[] = [
- { id: 'msg-u', role: 'user', content: 'run tools', createdAt: makeDate() },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'gmail',
- input: {},
- state: 'pending',
- },
- ],
- createdAt: makeDate(1),
- },
- ];
- const cancelledTree: InstanceAiAgentNode = {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'cancelled',
- cancellationReason: 'user',
- textContent: '',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- };
- const snapshots = [
- {
- tree: cancelledTree,
- runId: 'run_x',
- messageGroupId: 'mg_x',
- createdAt: makeDate(2),
- updatedAt: makeDate(2),
- },
- ];
-
- const result = parseStoredMessages(messages, snapshots);
-
- const assistant = result.find((m) => m.role === 'assistant');
- expect(assistant?.agentTree?.status).toBe('cancelled');
- const toolCall = assistant?.agentTree?.toolCalls.find((t) => t.toolCallId === 'tc-1');
- // No spinner that never resolves next to "You stopped this run".
- expect(toolCall?.isLoading).toBe(false);
- });
-
- it('should aggregate a multi-row cancelled turn into one bubble, not just the last row', () => {
- const messages: StoredAgentMessage[] = [
- { id: 'msg-u', role: 'user', content: 'Build something', createdAt: makeDate() },
- {
- id: 'msg-a1',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'gmail',
- input: {},
- state: 'resolved',
- output: {},
- },
- ],
- createdAt: makeDate(1),
- },
- {
- id: 'msg-a2',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-2',
- toolName: 'sheets',
- input: {},
- state: 'resolved',
- output: {},
- },
- ],
- createdAt: makeDate(2),
- },
- ];
- const cancelledTree: InstanceAiAgentNode = {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'cancelled',
- textContent: '',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- };
- const snapshots = [
- {
- tree: cancelledTree,
- runId: 'run_x',
- messageGroupId: 'mg_x',
- createdAt: makeDate(5),
- updatedAt: makeDate(5),
- },
- ];
-
- const result = parseStoredMessages(messages, snapshots);
-
- const assistants = result.filter((m) => m.role === 'assistant');
- expect(assistants).toHaveLength(1);
- // Both iterations' tool calls survive the dedup collapse, in chronological order.
- expect(assistants[0].agentTree?.toolCalls.map((t) => t.toolName)).toEqual([
- 'gmail',
- 'sheets',
- ]);
- });
-
- it('should reconstruct a completed run whose snapshot was lost (real-world: bus eviction)', () => {
- // Reproduces thread 058e9ce5: a long HITL build run whose snapshot was rebuilt
- // from an already-evicted event bus, persisting an empty `agent-001` tree even
- // though the run completed. The orchestrator's work survives in the message rows,
- // so the parser must reconstruct it. Real timestamps — the snapshot lands between
- // rows a3 and a4, so it pairs with a3 (pairing is timestamp-sensitive).
- const at = (iso: string): Date => new Date(iso);
- const tc = (toolCallId: string, toolName: string) => ({
- type: 'tool-call' as const,
- toolCallId,
- toolName,
- input: {},
- state: 'resolved' as const,
- output: {},
- });
- const messages: StoredAgentMessage[] = [
- {
- id: 'u',
- role: 'user',
- content: 'get my linear issues via http request',
- createdAt: at('2026-06-30T10:12:11.929Z'),
- },
- {
- id: 'a1',
- role: 'assistant',
- content: [tc('t1', 'load_skill'), tc('t2', 'credentials')],
- createdAt: at('2026-06-30T10:12:16.626Z'),
- },
- {
- id: 'a2',
- role: 'assistant',
- content: [tc('t3', 'nodes'), tc('t4', 'credentials')],
- createdAt: at('2026-06-30T10:12:23.292Z'),
- },
- {
- id: 'a3',
- role: 'assistant',
- content: [tc('t5', 'research')],
- createdAt: at('2026-06-30T10:13:07.951Z'),
- },
- {
- id: 'a4',
- role: 'assistant',
- content: [tc('t6', 'workspace_write_file')],
- createdAt: at('2026-06-30T10:13:30.262Z'),
- },
- {
- id: 'a5',
- role: 'assistant',
- content: [tc('t7', 'build-workflow')],
- createdAt: at('2026-06-30T10:14:05.669Z'),
- },
- {
- id: 'a6',
- role: 'assistant',
- content: [tc('t8', 'verify-built-workflow')],
- createdAt: at('2026-06-30T10:14:21.413Z'),
- },
- {
- id: 'a7',
- role: 'assistant',
- content: [{ type: 'text', text: 'The workflow is ready to test.' }],
- createdAt: at('2026-06-30T10:52:59.237Z'),
- },
- ];
- const emptyTree: InstanceAiAgentNode = {
- agentId: 'agent-001',
- role: 'orchestrator',
- status: 'completed',
- textContent: '',
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [],
- };
- const snapshots = [
- {
- tree: emptyTree,
- runId: 'run_YNR',
- messageGroupId: 'mg_N-C2',
- runIds: ['run_YNR'],
- createdAt: at('2026-06-30T10:13:08.274Z'),
- updatedAt: at('2026-06-30T10:13:08.274Z'),
- },
- ];
-
- const result = parseStoredMessages(messages, snapshots);
-
- // One bubble with the full orchestrator activity, not an empty `cancelled`-looking card.
- const assistants = result.filter((m) => m.role === 'assistant');
- expect(assistants).toHaveLength(1);
- expect(assistants[0].agentTree?.toolCalls.map((t) => t.toolName)).toEqual([
- 'load_skill',
- 'credentials',
- 'nodes',
- 'credentials',
- 'research',
- 'workspace_write_file',
- 'build-workflow',
- 'verify-built-workflow',
- ]);
- });
-
- it('should reconstruct tool calls when an empty snapshot is consumed as a leading orphan', () => {
+ it('should collapse an empty leading-orphan snapshot into the turn instead of rendering an empty card', () => {
// Real-world (thread 36a79497 / fal.ai build): a completed run whose snapshot was
// rebuilt from an already-evicted event bus, persisting an empty `agent-001` tree
// tagged with the turn's messageGroupId. Its createdAt sits at run start — before
// every assistant row — so it is consumed as a chronological *orphan* rather than
- // paired. The empty orphan must not clobber the message-derived flat tree when the
- // dedup pass collapses the turn.
+ // paired. The empty orphan must not surface as an assistant card of its own:
+ // group-id propagation folds it into the turn and the kept row renders from its
+ // own content, without the empty tree leaking through.
const tc = (toolCallId: string, toolName: string) => ({
type: 'tool-call' as const,
toolCallId,
@@ -928,20 +366,16 @@ describe('parseStoredMessages', () => {
const assistants = result.filter((m) => m.role === 'assistant');
expect(assistants).toHaveLength(1);
- // The empty orphan snapshot must not overwrite the reconstructed activity.
- expect(assistants[0].agentTree?.toolCalls.map((t) => t.toolName)).toEqual([
- 'workspace_write_file',
- 'workflows',
- ]);
- expect(assistants[0].agentTree?.textContent).toBe('The workflow is built and verified.');
+ // The non-renderable orphan tree is never authoritative.
+ expect(assistants[0].agentTree).toBeUndefined();
+ expect(assistants[0].content).toBe('The workflow is built and verified.');
+ expect(assistants[0].messageGroupId).toBe('mg_1');
});
it('should not surface an empty paired snapshot tree on a content-less assistant row', () => {
// Same invariant as the orphan guard, on the *pairing* path: a non-renderable
- // snapshot must never be authoritative. Here the empty snapshot pairs with an
- // assistant row that also has no text/tools, so there is no flat tree to fall
- // back to either — the row must end up with no tree, not the empty `agent-001`
- // snapshot tree leaking through.
+ // snapshot must never be authoritative — the row must end up with no tree, not
+ // the empty `agent-001` snapshot tree leaking through.
const messages: StoredAgentMessage[] = [
{ id: 'u', role: 'user', content: 'do nothing', createdAt: makeDate(0) },
{ id: 'a', role: 'assistant', content: [], createdAt: makeDate(1) },
@@ -1119,15 +553,12 @@ describe('parseStoredMessages', () => {
});
});
- it('should keep the snapshot tree when dedupe collapses in-flight checkpoint messages', () => {
- // Simulates the in-flight HITL case: the SDK hasn't committed
- // the turn to memory yet, so `loadInFlightCheckpointMessages`
- // surfaces several intermediate assistant messages from the
- // checkpoint blob. The snapshot was paired with a middle
- // message via timestamp matching, while a later message
- // (with no tree of its own) carries the latest text. Dedupe
- // must transfer the agentTree forward so the confirmation
- // card in the snapshot tree survives.
+ it('should keep the snapshot tree when dedupe collapses intra-turn assistant rows', () => {
+ // Simulates the in-flight HITL case: the turn has produced several
+ // assistant rows, and the snapshot was paired with a middle one via
+ // timestamp matching, while a later row (with no tree of its own)
+ // carries the latest text. Dedupe must transfer the agentTree
+ // forward so the confirmation card in the snapshot tree survives.
const snapshotTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
@@ -1199,55 +630,6 @@ describe('parseStoredMessages', () => {
expect(assistant.agentTree).toBe(snapshotTree);
expect(assistant.agentTree?.toolCalls[0].confirmation?.requestId).toBe('req-live');
});
-
- it('should apply renderHint correctly for known tool names', () => {
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'Go',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-1',
- toolName: 'delegate',
- input: {},
- state: 'resolved',
- output: 'ok',
- },
- {
- type: 'tool-call',
- toolCallId: 'tc-2',
- toolName: 'build-workflow',
- input: {},
- state: 'resolved',
- output: 'ok',
- },
- {
- type: 'tool-call',
- toolCallId: 'tc-3',
- toolName: 'create-tasks',
- input: {},
- state: 'resolved',
- output: 'ok',
- },
- ],
- createdAt: makeDate(1),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- const toolCalls = result[1].agentTree?.toolCalls ?? [];
- expect(toolCalls[0].renderHint).toBe('default');
- expect(toolCalls[1].renderHint).toBe('builder');
- expect(toolCalls[2].renderHint).toBe('planner');
- });
});
describe('internal enrichment stripping', () => {
@@ -1587,40 +969,9 @@ describe('parseStoredMessages', () => {
expect(result).toHaveLength(1);
expect(result[0].content).toBe('');
- // No tool calls and no text → no agentTree
+ // No snapshot → no agentTree
expect(result[0].agentTree).toBeUndefined();
});
-
- it('should extract tool calls from native parts', () => {
- const messages: StoredAgentMessage[] = [
- {
- id: 'msg-u',
- role: 'user',
- content: 'test',
- createdAt: makeDate(),
- },
- {
- id: 'msg-a',
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: 'tc-parts',
- toolName: 'create-tasks',
- input: { goal: 'x' },
- state: 'resolved',
- output: 'done',
- },
- ],
- createdAt: makeDate(1),
- },
- ];
-
- const result = parseStoredMessages(messages);
-
- expect(result[1].agentTree?.toolCalls).toHaveLength(1);
- expect(result[1].agentTree?.toolCalls[0].toolCallId).toBe('tc-parts');
- });
});
});
diff --git a/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts
index 93f2b6ef37b..45c3ae2ceb5 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts
@@ -10,7 +10,6 @@ import {
type OrphanConfirmationStore,
type RebuildSuspendedRunOutcome,
type RunFinishEventPublisher,
- type RunSnapshotCanceller,
type SuspendedRunRebuilder,
type SuspendedRunStateRegistry,
} from '../suspended-run-restorer.service';
@@ -19,7 +18,6 @@ type Mocks = {
logger: MockProxy;
pendingConfirmationRepo: MockProxy;
runState: MockProxy;
- dbSnapshotStorage: MockProxy;
eventBus: MockProxy;
rebuilder: MockProxy;
};
@@ -29,11 +27,9 @@ function createRestorer(): { restorer: SuspendedRunRestorer; mocks: Mocks } {
logger: mock(),
pendingConfirmationRepo: mock(),
runState: mock(),
- dbSnapshotStorage: mock(),
eventBus: mock(),
rebuilder: mock(),
};
- mocks.dbSnapshotStorage.markRunCancelled.mockResolvedValue();
mocks.rebuilder.resumeSuspendedRun.mockResolvedValue(null);
const restorer = new SuspendedRunRestorer(mocks);
@@ -99,7 +95,6 @@ describe('SuspendedRunRestorer — orphan restoration', () => {
}),
}),
);
- expect(mocks.dbSnapshotStorage.markRunCancelled).toHaveBeenCalledWith('thread-1', 'run-1');
});
it('throws when a suspended orphan lacks the pointers needed to resume', async () => {
@@ -174,7 +169,6 @@ describe('SuspendedRunRestorer — orphan restoration', () => {
expect(mocks.runState.suspendRun).not.toHaveBeenCalled();
expect(mocks.rebuilder.resumeSuspendedRun).not.toHaveBeenCalled();
expect(mocks.eventBus.publish).not.toHaveBeenCalled();
- expect(mocks.dbSnapshotStorage.markRunCancelled).not.toHaveBeenCalled();
});
it('rejects a stale inline-kind row without cancelling anything when the thread has a live run', async () => {
@@ -194,7 +188,6 @@ describe('SuspendedRunRestorer — orphan restoration', () => {
expect(result).toBeNull();
expect(mocks.eventBus.publish).not.toHaveBeenCalled();
- expect(mocks.dbSnapshotStorage.markRunCancelled).not.toHaveBeenCalled();
});
it('falls back to the terminal UserError when the rebuild fails', async () => {
diff --git a/packages/cli/src/modules/instance-ai/entities/index.ts b/packages/cli/src/modules/instance-ai/entities/index.ts
index 437dd33c641..7527519b783 100644
--- a/packages/cli/src/modules/instance-ai/entities/index.ts
+++ b/packages/cli/src/modules/instance-ai/entities/index.ts
@@ -1,7 +1,6 @@
export { InstanceAiThread } from './instance-ai-thread.entity';
export { InstanceAiMessage } from './instance-ai-message.entity';
export { InstanceAiResource } from './instance-ai-resource.entity';
-export { InstanceAiRunSnapshot } from './instance-ai-run-snapshot.entity';
export { InstanceAiIterationLog } from './instance-ai-iteration-log.entity';
export { InstanceAiCheckpoint } from './instance-ai-checkpoint.entity';
export { InstanceAiObservation } from './instance-ai-observation.entity';
diff --git a/packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts b/packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts
deleted file mode 100644
index 32760c3b9c2..00000000000
--- a/packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { WithTimestamps } from '@n8n/db';
-import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
-
-@Entity({ name: 'instance_ai_run_snapshots' })
-@Index(['threadId', 'messageGroupId'])
-@Index(['threadId', 'createdAt'])
-export class InstanceAiRunSnapshot extends WithTimestamps {
- @PrimaryColumn('uuid')
- threadId: string;
-
- @PrimaryColumn({ type: 'varchar', length: 36 })
- runId: string;
-
- @Column({ type: 'varchar', length: 36, nullable: true })
- messageGroupId: string | null;
-
- @Column({ type: 'simple-json', nullable: true })
- runIds: string[] | null;
-
- @Column({ type: 'text' })
- tree: string;
-
- @Column({ type: 'varchar', length: 64, nullable: true })
- traceId: string | null;
-
- @Column({ type: 'varchar', length: 64, nullable: true })
- spanId: string | null;
-
- @Column({ type: 'varchar', length: 36, nullable: true })
- langsmithRunId: string | null;
-
- @Column({ type: 'varchar', length: 36, nullable: true })
- langsmithTraceId: string | null;
-}
diff --git a/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts b/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts
index 20cf3c0899f..caca75db6b6 100644
--- a/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts
+++ b/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts
@@ -103,15 +103,6 @@ export class DurableLogMetrics {
this.eventService.emit('instance-ai-history-folded', { latencyMs, trees });
}
- /**
- * The parser is pure module code and keeps its own counter
- * (messageParserStats); this only forwards new activations to the
- * metrics pipeline.
- */
- notifyParserFallbacks(count: number): void {
- if (count > 0) this.eventService.emit('instance-ai-parser-fallback', { count });
- }
-
recordSweepRunExamined(): void {
this.sweep.runsExamined++;
}
diff --git a/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts b/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts
index 889ee1b2f2d..1d8b75fb4ef 100644
--- a/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts
+++ b/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts
@@ -42,7 +42,7 @@ export class InProcessEventBus implements InstanceAiEventBus {
* facts.
*/
publish(threadId: string, event: InstanceAiEvent): void {
- // Stamp publish time once — replays (SSE reconnect, snapshot rebuilds)
+ // Stamp publish time once — replays (SSE reconnect, history folds)
// rely on it to reconstruct real timing instead of processing time, and
// persisted events must carry it too.
if (event.ts === undefined) {
diff --git a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts
index cea0e17e456..476a783ba92 100644
--- a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts
+++ b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts
@@ -20,8 +20,6 @@ import {
type AgentTreeSnapshot,
} from '@n8n/instance-ai';
-import { DbSnapshotStorage } from './storage/db-snapshot-storage';
-
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -30,7 +28,6 @@ import { DurableLogMetrics } from './event-bus/durable-log-metrics';
import {
collectConfirmationRequestIds,
markExpiredConfirmations,
- messageParserStats,
parseStoredMessages,
} from './message-parser';
import { InstanceAiCheckpointRepository } from './repositories/instance-ai-checkpoint.repository';
@@ -45,15 +42,6 @@ export interface InstanceAiThreadLaunchMetadata {
sourceContext?: Record;
}
-function isAgentMessageLike(value: unknown): value is AgentDbMessage {
- return (
- typeof value === 'object' &&
- value !== null &&
- typeof (value as { id?: unknown }).id === 'string' &&
- 'role' in value
- );
-}
-
function isRestorableMessage(
value: Record & { createdAt: Date },
): value is AgentDbMessage & Record {
@@ -152,32 +140,6 @@ function expandRunIdsToGroups(
return [...expanded];
}
-function collectInFlightCheckpointMessages(checkpoints: InstanceAiCheckpoint[]): AgentDbMessage[] {
- const merged: AgentDbMessage[] = [];
- const seen = new Set();
- for (const checkpoint of checkpoints) {
- const stateMessages = checkpoint.state?.messageList?.messages ?? [];
- for (const candidate of stateMessages) {
- if (!isAgentMessageLike(candidate) || seen.has(candidate.id)) continue;
- seen.add(candidate.id);
- merged.push({
- ...candidate,
- createdAt:
- candidate.createdAt instanceof Date ? candidate.createdAt : new Date(candidate.createdAt),
- });
- }
- }
- return merged;
-}
-
-function mergeMessagesById(stored: AgentDbMessage[], extras: AgentDbMessage[]): AgentDbMessage[] {
- if (extras.length === 0) return stored;
- const byId = new Map();
- for (const message of stored) byId.set(message.id, message);
- for (const message of extras) if (!byId.has(message.id)) byId.set(message.id, message);
- return [...byId.values()].sort((a, b) => messageCreatedAtMs(a) - messageCreatedAtMs(b));
-}
-
/** Runs with a `run-start` fact but no terminal `run-finish` in the log. */
function collectUnfinishedRunIds(rows: Array<{ runId: string; event: InstanceAiEvent }>) {
const unfinished = new Set();
@@ -210,14 +172,12 @@ function collectSuspendedHostRunIds(checkpoints: InstanceAiCheckpoint[]): Set,
skipRunIds: Set,
skipGroupIds: Set,
-): { entries: AgentTreeSnapshot[]; skippedInFlight: boolean } {
+): { entries: AgentTreeSnapshot[] } {
// A run's run-start is its first fact, so the run-to-group mapping is
// complete before any grouping decision needs it.
const groupKeyByRun = new Map();
@@ -252,15 +212,11 @@ function buildLogDerivedSnapshots(
lastAt: Date;
};
const groups = new Map();
- let skippedInFlight = false;
for (const row of rows) {
if (!row.runId) continue;
const messageGroupId = groupKeyByRun.get(row.runId);
const key = messageGroupId ?? row.runId;
- if (skipRunIds.has(row.runId) || skipGroupKeys.has(key)) {
- skippedInFlight = true;
- continue;
- }
+ if (skipRunIds.has(row.runId) || skipGroupKeys.has(key)) continue;
let group = groups.get(key);
if (!group) {
group = {
@@ -297,7 +253,7 @@ function buildLogDerivedSnapshots(
updatedAt: group.lastAt,
});
}
- return { entries, skippedInFlight };
+ return { entries };
}
@Service()
@@ -308,7 +264,6 @@ export class InstanceAiMemoryService {
private readonly logger: Logger,
globalConfig: GlobalConfig,
private readonly agentMemory: TypeORMAgentMemory,
- private readonly dbSnapshotStorage: DbSnapshotStorage,
private readonly checkpointRepository: InstanceAiCheckpointRepository,
private readonly pendingConfirmationRepository: InstanceAiPendingConfirmationRepository,
private readonly eventLogRepository: InstanceAiEventLogRepository,
@@ -443,64 +398,27 @@ export class InstanceAiMemoryService {
// Hydrate trees only for the page we are about to render.
const pageWindow = historyWindow(result.messages, page, result.newerBoundaryAt);
- const loadStoredSnapshots = async (): Promise => {
- if (!pageWindow) return [];
- let snapshots = await this.dbSnapshotStorage
- .getForWindow(threadId, pageWindow)
- .catch((error) => {
- this.logger.warn('Failed to load agent tree snapshots', {
- threadId,
- error: error instanceof Error ? error.message : String(error),
- });
- return [] as AgentTreeSnapshot[];
- });
- // Exclude snapshots for active runs — they have no matching assistant
- // message in memory yet and would misalign the positional
- // snapshot-to-message matching in parseStoredMessages.
- if (options?.excludeRunIds?.length) {
- const excluded = new Set(options.excludeRunIds);
- snapshots = snapshots.filter((s) => !excluded.has(s.runId));
- }
- return snapshots;
- };
-
- // Loaded once, shared by the fold's suspension carve-out and the
- // in-flight message merge below.
+ // The fold's suspension carve-out: a HITL-suspended run legitimately has
+ // no run-finish, so its turn still folds (the confirmation card and the
+ // in-flight work are durable facts) instead of being skipped as in-flight.
const activeCheckpoints = await this.loadActiveCheckpoints(threadId);
// No window means an out-of-range older page: it has no message rows for
// a tree to pair with, and hydrating it unbounded would read the whole
// thread to render nothing.
//
- // Fold-on-read: history trees derive from the event log. Stored snapshots
- // are only loaded for the fold's pre-log/failure fallback, keeping the
- // heaviest instance-ai table out of the hot path.
+ // Fold-on-read: history trees derive from the event log.
const snapshots = !pageWindow
? []
: await this.foldSnapshotsFromLog(
threadId,
- loadStoredSnapshots,
collectSuspendedHostRunIds(activeCheckpoints),
pageWindow,
options?.excludeRunIds,
options?.excludeMessageGroupIds,
);
- // Surface the in-flight messages from any suspended checkpoint. The
- // user's prompt is persisted to memory on receipt, but the intermediate
- // assistant responses and pending tool-call from a turn suspended at HITL
- // are only committed after the turn completes, so until then they live
- // only inside the checkpoint blob. Without merging them in, a thread
- // waiting on a confirmation renders without those in-flight artifacts
- // after a page reload.
- const checkpointMessages = collectInFlightCheckpointMessages(activeCheckpoints);
- const storedMessages = mergeMessagesById(result.messages, checkpointMessages);
-
- const fallbacksBefore = messageParserStats.fallbackActivations;
- const messages = parseStoredMessages(storedMessages, snapshots);
- this.durableLogMetrics.notifyParserFallbacks(
- messageParserStats.fallbackActivations - fallbacksBefore,
- );
+ const messages = parseStoredMessages(result.messages, snapshots);
await this.flagExpiredConfirmations(messages);
const projectId = await this.agentMemory.getThreadProjectId(threadId);
@@ -508,10 +426,7 @@ export class InstanceAiMemoryService {
}
/**
- * Fold-on-read: history agent trees derive from the event log. Stored
- * snapshot rows keep being written but are neither read nor loaded here;
- * the lazy loader runs only when the thread has no log rows or the read
- * fails/derives nothing.
+ * Fold-on-read: history agent trees derive from the event log.
*
* Only the runs behind the requested page are read and folded, so a long
* thread costs the same per read as a short one. Run-start facts are
@@ -521,7 +436,6 @@ export class InstanceAiMemoryService {
*/
private async foldSnapshotsFromLog(
threadId: string,
- loadStoredSnapshots: () => Promise,
suspendedRunIds: ReadonlySet,
pageWindow: HistoryWindow,
excludeRunIds?: string[],
@@ -530,14 +444,8 @@ export class InstanceAiMemoryService {
const start = Date.now();
let rows;
try {
- // Pre-log thread: no run has a start fact, so stored snapshots still
- // render. The backfill migration gave every pre-existing run event
- // rows, so this branch is a dev-instance safety, not a design.
- // Checked on run starts rather than
- // on the windowed rows, which are also empty for a thread whose log
- // simply has nothing inside the page.
const runStarts = await this.eventLogRepository.getRunStarts(threadId);
- if (runStarts.length === 0) return await loadStoredSnapshots();
+ if (runStarts.length === 0) return [];
const windowedRunIds = await this.eventLogRepository.findRunIdsInWindow(threadId, pageWindow);
rows = await this.eventLogRepository.getForThreadRuns(
@@ -545,13 +453,14 @@ export class InstanceAiMemoryService {
expandRunIdsToGroups(windowedRunIds, runStarts),
);
} catch (error) {
+ // Degrade to messages-without-trees rather than failing the page read.
this.logger.warn('Failed to read Instance AI event log for history', {
threadId,
error: error instanceof Error ? error.message : String(error),
});
- return await loadStoredSnapshots();
+ return [];
}
- if (rows.length === 0) return await loadStoredSnapshots();
+ if (rows.length === 0) return [];
// Multi-main backstop (INS-913): the caller's exclusions come from
// per-process run state, which is empty on a main that is not driving
@@ -567,20 +476,12 @@ export class InstanceAiMemoryService {
if (!suspendedRunIds.has(runId)) skipRunIds.add(runId);
}
- const { entries, skippedInFlight } = buildLogDerivedSnapshots(
+ const { entries } = buildLogDerivedSnapshots(
rows,
skipRunIds,
new Set(excludeMessageGroupIds ?? []),
);
- if (entries.length === 0) {
- // Emptied by exclusion: the thread's only renderable content is the
- // in-flight group. Render nothing rather than fall back — the loader
- // filters stored snapshots by exact runId only, so a completed
- // sibling's snapshot would resurrect exactly the in-flight group
- // state the exclusion keeps out of history.
- if (skippedInFlight) return [];
- return await loadStoredSnapshots();
- }
+ if (entries.length === 0) return [];
entries.sort((a, b) => (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0));
this.durableLogMetrics.recordFoldRead(Date.now() - start, entries.length);
@@ -621,13 +522,6 @@ export class InstanceAiMemoryService {
}
}
- async getLatestRunSnapshot(
- threadId: string,
- options?: { messageGroupId?: string; runId?: string },
- ): Promise {
- return await this.dbSnapshotStorage.getLatest(threadId, options);
- }
-
/**
* Verify that a thread belongs to a specific user.
* Returns true if the thread exists and is owned by the user.
diff --git a/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts
index 8a0c65873d2..242dc4d0f57 100644
--- a/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts
+++ b/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts
@@ -1,4 +1,4 @@
-import type { InstanceAiAgentNode, InstanceAiErrorEvent, InstanceAiEvent } from '@n8n/api-types';
+import type { InstanceAiErrorEvent, InstanceAiEvent } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import {
@@ -21,7 +21,6 @@ import type { Telemetry } from '@/telemetry';
import type { InProcessEventBus } from './event-bus/in-process-event-bus';
import type { InstanceAiErrorReporterService } from './instance-ai-error-reporter.service';
-import type { DbSnapshotStorage } from './storage/db-snapshot-storage';
import type { SuspendedThreadPersistenceService } from './suspended-thread-persistence.service';
import type {
InstanceAiTracingService,
@@ -38,55 +37,6 @@ function getBackgroundOutcomeResponseId(outcome: TerminalOutcome): string {
return `background-outcome:${outcome.id}`;
}
-function createTerminalOutcomeAgentTree(
- outcome: TerminalOutcome,
- responseId: string,
-): InstanceAiAgentNode {
- return {
- agentId: orchestratorAgentId(outcome.runId),
- role: 'orchestrator',
- status:
- outcome.status === 'cancelled'
- ? 'cancelled'
- : outcome.status === 'failed'
- ? 'error'
- : 'completed',
- textContent: outcome.userFacingMessage,
- reasoning: '',
- toolCalls: [],
- children: [],
- timeline: [{ type: 'text', content: outcome.userFacingMessage, responseId }],
- };
-}
-
-function appendTerminalOutcomeToAgentTree(
- tree: InstanceAiAgentNode,
- outcome: TerminalOutcome,
- responseId: string,
-): { tree: InstanceAiAgentNode; appended: boolean } {
- const text = outcome.userFacingMessage.trim();
- if (!text) return { tree, appended: false };
-
- const alreadyInTimeline = tree.timeline.some(
- (entry) => entry.type === 'text' && entry.responseId === responseId,
- );
- if (alreadyInTimeline) {
- return { tree, appended: false };
- }
-
- return {
- appended: true,
- tree: {
- ...tree,
- textContent: tree.textContent ? `${tree.textContent}\n\n${outcome.userFacingMessage}` : text,
- timeline: [
- ...tree.timeline,
- { type: 'text', content: outcome.userFacingMessage, responseId },
- ],
- },
- };
-}
-
// The slice of each collaborator the terminal-outcome coordinator actually
// uses. Anchored to the concrete types via `Pick` so the signatures stay in
// sync with the source.
@@ -100,11 +50,6 @@ export type InstanceAiTerminalOutcomeEventBus = Pick;
};
-export type InstanceAiTerminalOutcomeSnapshotStorage = Pick<
- DbSnapshotStorage,
- 'getLatest' | 'save' | 'updateLast'
->;
-
export type InstanceAiTerminalOutcomeTelemetry = Pick;
export type InstanceAiTerminalOutcomeErrorReporter = Pick;
@@ -126,7 +71,6 @@ export type InstanceAiTerminalOutcomeTracing = Pick<
export interface InstanceAiTerminalOutcomeServiceOptions {
eventBus: InstanceAiTerminalOutcomeEventBus;
- dbSnapshotStorage: InstanceAiTerminalOutcomeSnapshotStorage;
agentMemory: PatchableThreadMemory;
telemetry: InstanceAiTerminalOutcomeTelemetry;
errorReporter: InstanceAiTerminalOutcomeErrorReporter;
@@ -144,15 +88,6 @@ export interface InstanceAiTerminalOutcomeServiceOptions {
status: 'completed' | 'cancelled' | 'errored',
reason?: string,
) => void;
- /**
- * Persists the orchestrator agent-tree snapshot. Owned by the run loop until
- * snapshot persistence is extracted into its own collaborator.
- */
- saveAgentTreeSnapshot: (
- threadId: string,
- runId: string,
- snapshotStorage: DbSnapshotStorage,
- ) => Promise;
}
/**
@@ -169,7 +104,7 @@ export interface InstanceAiTerminalOutcomeServiceOptions {
*
* 2. **Terminal-outcome durability.** Background tasks finish out of band from
* the foreground run, so their user-facing summary is persisted to
- * {@link TerminalOutcomeStorage} and the conversation snapshot, then
+ * {@link TerminalOutcomeStorage} and published as a durable text-block, then
* replayed on reconnect so a closed SSE stream never drops the result.
*/
export class InstanceAiTerminalOutcomeService {
@@ -179,8 +114,6 @@ export class InstanceAiTerminalOutcomeService {
private readonly eventBus: InstanceAiTerminalOutcomeEventBus;
- private readonly dbSnapshotStorage: InstanceAiTerminalOutcomeSnapshotStorage;
-
private readonly agentMemory: PatchableThreadMemory;
private readonly telemetry: InstanceAiTerminalOutcomeTelemetry;
@@ -197,11 +130,8 @@ export class InstanceAiTerminalOutcomeService {
private readonly publishRunFinish: InstanceAiTerminalOutcomeServiceOptions['publishRunFinish'];
- private readonly saveAgentTreeSnapshot: InstanceAiTerminalOutcomeServiceOptions['saveAgentTreeSnapshot'];
-
constructor(options: InstanceAiTerminalOutcomeServiceOptions) {
this.eventBus = options.eventBus;
- this.dbSnapshotStorage = options.dbSnapshotStorage;
this.agentMemory = options.agentMemory;
this.telemetry = options.telemetry;
this.errorReporter = options.errorReporter;
@@ -210,7 +140,6 @@ export class InstanceAiTerminalOutcomeService {
this.suspendedThreads = options.suspendedThreads;
this.tracing = options.tracing;
this.publishRunFinish = options.publishRunFinish;
- this.saveAgentTreeSnapshot = options.saveAgentTreeSnapshot;
}
async evaluateTerminalResponse(
@@ -342,7 +271,6 @@ export class InstanceAiTerminalOutcomeService {
threadId: string;
runId: string;
abortController: AbortController;
- snapshotStorage: DbSnapshotStorage;
tracing?: InstanceAiTraceContext;
}): Promise {
this.runState.cancelThread(args.threadId);
@@ -358,7 +286,6 @@ export class InstanceAiTerminalOutcomeService {
'errored',
'I need your input to continue, but I could not display the prompt. Please try again.',
);
- await this.saveAgentTreeSnapshot(args.threadId, args.runId, args.snapshotStorage);
return {
status: 'error',
reason: 'invalid_confirmation_payload',
@@ -392,10 +319,7 @@ export class InstanceAiTerminalOutcomeService {
};
}
- async replayUndeliveredTerminalOutcomes(
- threadId: string,
- options: { delivery?: 'snapshot' | 'event' } = {},
- ): Promise {
+ async replayUndeliveredTerminalOutcomes(threadId: string): Promise {
const storage = this.createTerminalOutcomeStorage();
const noOutcomes: TerminalOutcome[] = [];
const persistedOutcomes = await storage.getUndelivered(threadId).catch((error) => {
@@ -413,13 +337,12 @@ export class InstanceAiTerminalOutcomeService {
outcomes.set(outcome.id, outcome);
}
const persistedOutcomeIds = new Set(persistedOutcomes.map((outcome) => outcome.id));
- const delivery = options.delivery ?? 'snapshot';
for (const outcome of outcomes.values()) {
const responseId = getBackgroundOutcomeResponseId(outcome);
- let snapshotDelivered = false;
+ let delivery: 'published' | 'already-emitted' | 'dropped' = 'dropped';
try {
- snapshotDelivered = await this.persistTerminalOutcomeLineToSnapshot(outcome, responseId);
+ delivery = await this.publishTerminalOutcomeLine(outcome, responseId);
} catch (error) {
this.logger.warn('Failed to replay Instance AI terminal outcome', {
threadId,
@@ -427,29 +350,11 @@ export class InstanceAiTerminalOutcomeService {
taskId: outcome.taskId,
error: getErrorMessage(error),
});
- if (delivery === 'event') {
- const published = await this.publishTerminalOutcomeLine(outcome, responseId);
- this.telemetry.track('instance_ai_terminal_response_decision', {
- thread_id: threadId,
- run_id: outcome.runId,
- message_group_id: outcome.messageGroupId,
- task_id: outcome.taskId,
- source: 'terminal_outcome_replay',
- status: outcome.status,
- action: published ? 'replay_event' : 'already-emitted',
- visibility_source: 'background-outcome',
- });
- }
- continue;
}
+ // Left undelivered on purpose: the next replay retries it.
+ if (delivery === 'dropped') continue;
- if (!snapshotDelivered) continue;
-
- let action = 'replay_snapshot';
- if (delivery === 'event') {
- const published = await this.publishTerminalOutcomeLine(outcome, responseId);
- action = published ? 'replay_event' : 'already-emitted';
- }
+ const action = delivery === 'published' ? 'replay_event' : 'already-emitted';
if (persistedOutcomeIds.has(outcome.id)) {
await storage
@@ -477,47 +382,23 @@ export class InstanceAiTerminalOutcomeService {
}
}
- private async persistTerminalOutcomeLineToSnapshot(
- outcome: TerminalOutcome,
- responseId: string,
- ): Promise {
- const snapshot = await this.dbSnapshotStorage.getLatest(outcome.threadId, {
- messageGroupId: outcome.messageGroupId,
- runId: outcome.runId,
- });
- if (!snapshot) {
- await this.dbSnapshotStorage.save(
- outcome.threadId,
- createTerminalOutcomeAgentTree(outcome, responseId),
- outcome.runId,
- {
- messageGroupId: outcome.messageGroupId,
- runIds: [outcome.runId],
- },
- );
- return true;
- }
-
- const { tree } = appendTerminalOutcomeToAgentTree(snapshot.tree, outcome, responseId);
- const runIds = new Set(snapshot.runIds ?? [snapshot.runId]);
- runIds.add(outcome.runId);
- await this.dbSnapshotStorage.updateLast(outcome.threadId, tree, snapshot.runId, {
- messageGroupId: snapshot.messageGroupId ?? outcome.messageGroupId,
- runIds: [...runIds],
- langsmithRunId: snapshot.langsmithRunId,
- langsmithTraceId: snapshot.langsmithTraceId,
- });
- return true;
- }
-
+ /**
+ * Publish the outcome line as a durable text-block and read it back.
+ * `publish` only enqueues — the drain persists asynchronously and settles
+ * flush waiters even when it had to drop a batch — so only the read-back
+ * makes the line trustworthy as a delivery record. 'dropped' means it never
+ * reached the log; the caller must leave the outcome undelivered so a later
+ * replay retries it.
+ */
private async publishTerminalOutcomeLine(
outcome: TerminalOutcome,
responseId: string,
- ): Promise {
+ ): Promise<'published' | 'already-emitted' | 'dropped'> {
+ const isOutcomeLine = (event: InstanceAiEvent) => event.responseId === responseId;
const alreadyPublished = (
await this.eventBus.getEventsForRun(outcome.threadId, outcome.runId)
- ).some((event) => event.responseId === responseId);
- if (alreadyPublished) return false;
+ ).some(isOutcomeLine);
+ if (alreadyPublished) return 'already-emitted';
this.eventBus.publish(outcome.threadId, {
type: 'text-block',
@@ -526,7 +407,12 @@ export class InstanceAiTerminalOutcomeService {
responseId,
payload: { text: outcome.userFacingMessage },
});
- return true;
+ // The adapter's read settles the thread's drain before querying, so the
+ // block is either in the log by now or was dropped.
+ const durable = (await this.eventBus.getEventsForRun(outcome.threadId, outcome.runId)).some(
+ isOutcomeLine,
+ );
+ return durable ? 'published' : 'dropped';
}
async recordBackgroundTerminalOutcome(task: ManagedBackgroundTask): Promise {
@@ -553,7 +439,29 @@ export class InstanceAiTerminalOutcomeService {
}
const responseId = getBackgroundOutcomeResponseId(outcome);
- const published = await this.publishTerminalOutcomeLine(outcome, responseId);
+ let delivery: 'published' | 'already-emitted' | 'dropped' = 'dropped';
+ try {
+ delivery = await this.publishTerminalOutcomeLine(outcome, responseId);
+ } catch (error) {
+ this.logger.warn('Failed to publish Instance AI terminal outcome line', {
+ threadId: task.threadId,
+ runId: task.runId,
+ taskId: task.taskId,
+ error: getErrorMessage(error),
+ });
+ }
+ if (delivery === 'dropped') {
+ // Leave the outcome undelivered — the metadata row (or the pending-map
+ // entry when the upsert failed too) makes the next replay retry it.
+ this.telemetry.track('instance_ai_terminal_outcome_persistence_failure', {
+ thread_id: task.threadId,
+ run_id: task.runId,
+ task_id: task.taskId,
+ status: outcome.status,
+ phase: 'event',
+ });
+ return;
+ }
this.telemetry.track('instance_ai_terminal_response_decision', {
thread_id: task.threadId,
@@ -562,30 +470,11 @@ export class InstanceAiTerminalOutcomeService {
task_id: task.taskId,
source: 'background_outcome',
status: outcome.status,
- action: published ? 'emit' : 'already-emitted',
+ action: delivery === 'published' ? 'emit' : 'already-emitted',
visibility_source: 'background-outcome',
});
- let snapshotDelivered = false;
- try {
- snapshotDelivered = await this.persistTerminalOutcomeLineToSnapshot(outcome, responseId);
- } catch (error) {
- this.logger.warn('Failed to persist Instance AI terminal outcome line to snapshot', {
- threadId: task.threadId,
- runId: task.runId,
- taskId: task.taskId,
- error: getErrorMessage(error),
- });
- this.telemetry.track('instance_ai_terminal_outcome_persistence_failure', {
- thread_id: task.threadId,
- run_id: task.runId,
- task_id: task.taskId,
- status: outcome.status,
- phase: 'snapshot',
- });
- }
-
- if (!persisted || !snapshotDelivered) return;
+ if (!persisted) return;
try {
await this.createTerminalOutcomeStorage().markDelivered(
diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts
index 61448bbafa5..83d3068b795 100644
--- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts
+++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts
@@ -23,11 +23,7 @@ import {
InstanceAiEvalSeedDataTableRowsRequest,
findUnbackedSeedWorkflowTools,
} from '@n8n/api-types';
-import type {
- InstanceAiAdminSettingsResponse,
- InstanceAiAgentNode,
- InstanceAiEvent,
-} from '@n8n/api-types';
+import type { InstanceAiAdminSettingsResponse, InstanceAiEvent } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
@@ -46,7 +42,7 @@ import {
Body,
Query,
} from '@n8n/decorators';
-import type { AgentTreeSnapshot, StoredEvent } from '@n8n/instance-ai';
+import type { StoredEvent } from '@n8n/instance-ai';
import {
buildAgentTreeFromEvents,
clearedAgentBuilderTargetMetadata,
@@ -94,36 +90,6 @@ const KEEP_ALIVE_INTERVAL_MS = 15_000;
export class InstanceAiController {
private readonly gatewayApiKey: string;
- private static getTreeRichnessScore(tree: InstanceAiAgentNode): number {
- let score = 0;
- const stack = [tree];
-
- while (stack.length > 0) {
- const node = stack.pop()!;
- score += 100;
- score += node.toolCalls.length * 10;
- score += node.timeline.length * 2;
- score += (node.planItems?.length ?? 0) * 20;
- score += node.toolCalls.filter((toolCall) => toolCall.confirmation).length * 50;
- score += node.children.length * 25;
- stack.push(...node.children);
- }
-
- return score;
- }
-
- private static selectBootstrapTree(
- eventTree: InstanceAiAgentNode,
- persistedTree?: InstanceAiAgentNode,
- ): InstanceAiAgentNode {
- if (!persistedTree) return eventTree;
-
- return InstanceAiController.getTreeRichnessScore(persistedTree) >
- InstanceAiController.getTreeRichnessScore(eventTree)
- ? persistedTree
- : eventTree;
- }
-
constructor(
private readonly instanceAiService: InstanceAiService,
private readonly gatewayService: InstanceAiGatewayService,
@@ -349,9 +315,7 @@ export class InstanceAiController {
// 2. Re-publish any terminal outcomes that never reached the client.
if (ownership === 'owned') {
- await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId, {
- delivery: 'event',
- });
+ await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId);
}
// 3. Set SSE headers.
@@ -373,7 +337,7 @@ export class InstanceAiController {
const cursor =
Number.isFinite(parsedHeader) && parsedHeader >= 0 ? parsedHeader : (query.lastEventId ?? 0);
- // 5. Collect live message groups and fetch their persisted snapshots.
+ // 5. Collect live message groups.
// Multiple groups can be active simultaneously when a background task
// from an older turn outlives its original turn.
const threadStatus = this.instanceAiService.getThreadStatus(threadId);
@@ -406,23 +370,6 @@ export class InstanceAiController {
}
}
- const persistedSnapshots = new Map();
- for (const [groupId, group] of liveGroups) {
- persistedSnapshots.set(
- groupId,
- await this.memoryService.getLatestRunSnapshot(threadId, {
- messageGroupId: groupId,
- // Use the group's own latest runId — NOT the thread-global
- // activeRunId, which belongs to the current orchestrator turn and
- // would be wrong for background groups from older turns.
- runId: group.runIds.at(-1),
- }),
- );
- }
-
- // The client may have disconnected during the awaits above.
- if (closed) return;
-
// 6b (used by both arms below). Emit one run-sync control frame for a live
// message group. Each frame uses a named SSE event type
// (event: run-sync) with NO id: field so the browser's lastEventId is
@@ -432,14 +379,9 @@ export class InstanceAiController {
group: { runIds: string[]; status: 'active' | 'suspended' | 'background' },
runEvents: InstanceAiEvent[],
) => {
- const persistedSnapshot = persistedSnapshots.get(groupId);
- if (runEvents.length === 0 && !persistedSnapshot) return;
+ if (runEvents.length === 0) return;
- const eventTree = buildAgentTreeFromEvents(runEvents);
- const agentTree = InstanceAiController.selectBootstrapTree(
- eventTree,
- persistedSnapshot?.tree,
- );
+ const agentTree = buildAgentTreeFromEvents(runEvents);
res.write(
`event: run-sync\ndata: ${JSON.stringify({
runId: group.runIds.at(-1),
diff --git a/packages/cli/src/modules/instance-ai/instance-ai.module.ts b/packages/cli/src/modules/instance-ai/instance-ai.module.ts
index 88503feb862..31a60df09ce 100644
--- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts
+++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts
@@ -85,7 +85,6 @@ export class InstanceAiModule implements ModuleInterface {
const { InstanceAiThread } = await import('./entities/instance-ai-thread.entity.js');
const { InstanceAiMessage } = await import('./entities/instance-ai-message.entity.js');
const { InstanceAiResource } = await import('./entities/instance-ai-resource.entity.js');
- const { InstanceAiRunSnapshot } = await import('./entities/instance-ai-run-snapshot.entity.js');
const { InstanceAiIterationLog } = await import(
'./entities/instance-ai-iteration-log.entity.js'
);
@@ -112,7 +111,6 @@ export class InstanceAiModule implements ModuleInterface {
InstanceAiThread,
InstanceAiMessage,
InstanceAiResource,
- InstanceAiRunSnapshot,
InstanceAiIterationLog,
InstanceAiCheckpoint,
InstanceAiPendingConfirmation,
diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts
index deb5a4171f4..3f49e1bd137 100644
--- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts
+++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts
@@ -52,7 +52,6 @@ import {
createDomainAccessTracker,
BackgroundTaskManager,
MemoryTaskRegistry,
- buildAgentTreeFromEvents,
classifyAttachments,
buildAttachmentManifest,
getDateTimeSection,
@@ -187,11 +186,11 @@ import {
type PlannedWorkflowVerificationGate,
type PlannedWorkflowVerificationTracker,
} from './planned-task-action-runner';
+import { InstanceAiEventLogRepository } from './repositories/instance-ai-event-log.repository';
import { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository';
import { InstanceAiThreadGrantRepository } from './repositories/instance-ai-thread-grant.repository';
import { InstanceAiSandboxService, type RuntimeSandboxEntry } from './sandbox';
import { DbIterationLogStorage } from './storage/db-iteration-log-storage';
-import { DbSnapshotStorage } from './storage/db-snapshot-storage';
import { TypeORMAgentCheckpointStore } from './storage/typeorm-agent-checkpoint-store';
import { TypeORMAgentMemory } from './storage/typeorm-agent-memory';
import { isStreamTransportError } from './stream-transport-error';
@@ -630,7 +629,6 @@ type UnclaimedResumeContext = {
runId: string;
user: User;
signal: AbortSignal;
- snapshotStorage: DbSnapshotStorage;
tracing?: InstanceAiTraceContext;
messageGroupId?: string;
unregisteredResumeTracing?: InstanceAiTraceContext;
@@ -823,7 +821,7 @@ export class InstanceAiService {
private readonly threadGrantRepo: InstanceAiThreadGrantRepository,
private readonly pendingConfirmationRepo: InstanceAiPendingConfirmationRepository,
private readonly urlService: UrlService,
- private readonly dbSnapshotStorage: DbSnapshotStorage,
+ private readonly eventLogRepository: InstanceAiEventLogRepository,
private readonly dbIterationLogStorage: DbIterationLogStorage,
private readonly instanceWriteAccess: InstanceWriteAccessService,
private readonly telemetry: Telemetry,
@@ -862,7 +860,6 @@ export class InstanceAiService {
logger: this.logger,
pendingConfirmationRepo: this.pendingConfirmationRepo,
runState: this.runState,
- dbSnapshotStorage: this.dbSnapshotStorage,
eventBus: this.eventBus,
rebuilder: {
rebuildSuspendedRun: this.rebuildSuspendedRunFromCheckpoint.bind(this),
@@ -894,7 +891,7 @@ export class InstanceAiService {
getEventsForRun: async (threadId, runId) => await this.readRunEvents(threadId, [runId]),
},
runState: this.runState,
- dbSnapshotStorage: this.dbSnapshotStorage,
+ eventLog: this.eventLogRepository,
aiService: this.aiService,
});
this.sandboxService = new InstanceAiSandboxService({
@@ -915,7 +912,6 @@ export class InstanceAiService {
getEventsForRun: async (threadId, runId) => await this.readRunEvents(threadId, [runId]),
getEventsForRuns: async (threadId, runIds) => await this.readRunEvents(threadId, runIds),
},
- dbSnapshotStorage: this.dbSnapshotStorage,
agentMemory: this.agentMemory,
telemetry: this.telemetry,
errorReporter: this.instanceAiErrorReporter,
@@ -926,8 +922,6 @@ export class InstanceAiService {
publishRunFinish: (threadId, runId, status, reason) => {
this.publishRunFinish(threadId, runId, status, reason);
},
- saveAgentTreeSnapshot: async (threadId, runId, snapshotStorage) =>
- await this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage),
});
this.defaultTimeZone = globalConfig.generic.timezone;
const restEndpoint = globalConfig.endpoints.rest;
@@ -1434,15 +1428,7 @@ export class InstanceAiService {
status: 'cancelled',
},
});
- void this.terminalOutcome.recordBackgroundTerminalOutcome(task).finally(() => {
- void this.saveAgentTreeSnapshot(
- threadId,
- task.runId,
- this.dbSnapshotStorage,
- true,
- task.messageGroupId,
- );
- });
+ void this.terminalOutcome.recordBackgroundTerminalOutcome(task);
if (user) {
void this.handlePlannedTaskSettlement(user, task, 'cancelled', { reschedule: false });
}
@@ -1498,18 +1484,7 @@ export class InstanceAiService {
payload: { role: task.role, result: '', status: 'cancelled' },
});
- // Persist the updated agent tree so cancelled status survives page reload.
- // The onSettled callback in executeTask is skipped for aborted tasks,
- // so we must save the snapshot explicitly here.
- void this.terminalOutcome.recordBackgroundTerminalOutcome(task).finally(() => {
- void this.saveAgentTreeSnapshot(
- threadId,
- task.runId,
- this.dbSnapshotStorage,
- true,
- task.messageGroupId,
- );
- });
+ void this.terminalOutcome.recordBackgroundTerminalOutcome(task);
const user = this.runState.getThreadUser(threadId);
if (user) {
@@ -1733,13 +1708,6 @@ export class InstanceAiService {
},
onSettled: async (task) => {
await this.terminalOutcome.recordBackgroundTerminalOutcome(task);
- await this.saveAgentTreeSnapshot(
- threadId,
- runId,
- this.dbSnapshotStorage,
- true,
- messageGroupId,
- );
},
});
@@ -1880,10 +1848,9 @@ export class InstanceAiService {
// `instance_ai_pending_confirmations` row survives the restart and
// `handleOrphanedConfirmation` will issue the user-visible
// `restart_lost_confirmation` UserError + `run-finish` when (if)
- // the user clicks confirm. If we publish run-finish + re-save the
- // snapshot here, we'd permanently overwrite the plan/ask card with
- // a `status: 'cancelled'` tree before the user has a chance to see
- // it on reload.
+ // the user clicks confirm. If we publish run-finish here, the fold
+ // would render the plan/ask card as cancelled before the user has a
+ // chance to see it on reload.
if (threadsWithPendingHitl.has(run.threadId)) {
await this.tracing.finalizeRunTracing(run.runId, run.tracing, {
status: 'cancelled',
@@ -1892,20 +1859,16 @@ export class InstanceAiService {
// Record the policy *before* the abort fires so the run's catch
// handler (which runs synchronously off the abort) sees the
// flag. The catch path consults `shouldPreserveHitlOnShutdown`
- // and skips the terminal-fallback / run-finish / snapshot
- // writes that would otherwise overwrite the plan/ask card.
+ // and skips the terminal-fallback / run-finish writes that
+ // would otherwise overwrite the plan/ask card.
this.preserveHitlOnShutdown.add(run.runId);
run.abortController.abort();
continue;
}
- // Truly mid-stream run: publish run-finish first so the terminal
- // event lands in the event bus before the snapshot reads it;
- // saveAgentTreeSnapshot rebuilds the tree from the bus, so without
- // this order the persisted tree would still look mid-stream after
- // the process is gone.
+ // Truly mid-stream run: the durable run-finish is all history needs —
+ // the fold derives the terminal tree from the log after restart.
this.publishRunFinish(run.threadId, run.runId, 'cancelled', 'service_shutdown');
- await this.persistShutdownSnapshot(run.threadId, run.runId, run.messageGroupId);
await this.tracing.finalizeRunTracing(run.runId, run.tracing, {
status: 'cancelled',
reason: 'service_shutdown',
@@ -2128,33 +2091,6 @@ export class InstanceAiService {
}
}
- /**
- * Save the in-flight agent tree as a terminal snapshot so the UI doesn't
- * sit on a half-rendered turn after the process restarts. Best-effort: a
- * DB write failure here must not block the rest of shutdown.
- */
- private async persistShutdownSnapshot(
- threadId: string,
- runId: string,
- messageGroupId: string | undefined,
- ): Promise {
- try {
- await this.saveAgentTreeSnapshot(
- threadId,
- runId,
- this.dbSnapshotStorage,
- true,
- messageGroupId,
- );
- } catch (error: unknown) {
- this.logger.warn('Failed to persist shutdown snapshot', {
- threadId,
- runId,
- error: getErrorMessage(error),
- });
- }
- }
-
private createAgentMemoryOptions(user: User, threadId: string, runId: string) {
return {
observationalMemory: {
@@ -2345,11 +2281,8 @@ export class InstanceAiService {
* reconnecting client never misses a result that completed while its stream
* was closed. Delegates to {@link InstanceAiTerminalOutcomeService}.
*/
- async replayUndeliveredTerminalOutcomes(
- threadId: string,
- options: { delivery?: 'snapshot' | 'event' } = {},
- ): Promise {
- await this.terminalOutcome.replayUndeliveredTerminalOutcomes(threadId, options);
+ async replayUndeliveredTerminalOutcomes(threadId: string): Promise {
+ await this.terminalOutcome.replayUndeliveredTerminalOutcomes(threadId);
}
private async syncPlannedTasksToUi(threadId: string, graph: PlannedTaskGraph): Promise {
@@ -2545,7 +2478,6 @@ export class InstanceAiService {
const taskStorage = new ThreadTaskStorage(memory);
const iterationLog = this.dbIterationLogStorage;
- const snapshotStorage = this.dbSnapshotStorage;
const workflowLoopStorage = new WorkflowLoopStorage(memory);
const workflowTasks = this.createWorkflowTaskServiceWithUiSync(
threadId,
@@ -2653,7 +2585,6 @@ export class InstanceAiService {
checkpointStore: this.checkpointStore,
eventBus: this.eventBus,
logger: this.logger,
- outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
trackTelemetry: (eventName, properties) => {
this.telemetry.track(eventName, redactTelemetryProperties(properties));
},
@@ -2714,19 +2645,10 @@ export class InstanceAiService {
messageGroupId,
kind: 'inline',
});
-
- // Inline HITL (plan approval / sub-agent asks)
- // keeps the orchestrator run active, so the normal suspended/completed
- // snapshot paths do not execute. Queue a snapshot after the current
- // confirmation-request event is published to preserve refresh recovery.
- queueMicrotask(() => {
- void this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
- });
});
},
cancelBackgroundTask: async (taskId) => this.cancelBackgroundTask(threadId, taskId),
- spawnBackgroundTask: (opts) =>
- this.spawnBackgroundTask(runId, opts, snapshotStorage, messageGroupId),
+ spawnBackgroundTask: (opts) => this.spawnBackgroundTask(runId, opts, messageGroupId),
touchRun: () => this.runState.touchActiveRun(threadId),
touchBackgroundTask: (taskId) => this.backgroundTasks.touchTask(threadId, taskId),
plannedTaskService,
@@ -2748,7 +2670,6 @@ export class InstanceAiService {
memory,
taskStorage,
iterationLog,
- snapshotStorage,
workflowTasks,
plannedTaskService,
modelId,
@@ -3665,7 +3586,6 @@ export class InstanceAiService {
let tracing: InstanceAiTraceContext | undefined;
let messageTraceFinalization: MessageTraceFinalization | undefined;
let aiCreatedWorkflowIds: Set | undefined;
- let activeSnapshotStorage: DbSnapshotStorage | undefined;
let messageId = '';
let streamReached = false;
/** Declared out here so the terminal handlers below can see it. */
@@ -3766,14 +3686,24 @@ export class InstanceAiService {
this.runDebugBuffer.ensure(runId, threadId, buildRunDebugLabel({ message, resumeReason }));
}
- // Publish run-start (includes userId for audit trail attribution)
+ // Publish run-start (includes userId for audit trail attribution). The
+ // LangSmith ids ride here so user feedback can annotate the trace after
+ // a restart — the durable log is their only home.
const traceId = tracing?.rootRun.otelTraceId;
+ const langsmithRunId = tracing?.rootRun.id;
+ const langsmithTraceId = tracing?.rootRun.traceId;
this.eventBus.publish(threadId, {
type: 'run-start',
runId,
agentId: orchestratorAgentId(runId),
userId: user.id,
- payload: { messageId, messageGroupId, ...(traceId ? { traceId } : {}) },
+ payload: {
+ messageId,
+ messageGroupId,
+ ...(traceId ? { traceId } : {}),
+ ...(langsmithRunId ? { langsmithRunId } : {}),
+ ...(langsmithTraceId ? { langsmithTraceId } : {}),
+ },
});
// Check if already cancelled before starting agent work
@@ -3802,13 +3732,10 @@ export class InstanceAiService {
executionPushRef,
proxyRunConfig,
);
- activeSnapshotStorage = environment.snapshotStorage;
-
const {
context,
memory,
taskStorage,
- snapshotStorage,
workflowTasks,
plannedTaskService,
modelId,
@@ -4110,7 +4037,6 @@ export class InstanceAiService {
logger: this.logger,
onActivity: () => this.runState.touchActiveRun(threadId),
stopSignal,
- outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
})
: await streamAgentRun(agent as StreamableAgent, streamInput, streamOptions, {
@@ -4122,7 +4048,6 @@ export class InstanceAiService {
logger: this.logger,
onActivity: () => this.runState.touchActiveRun(threadId),
stopSignal,
- outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
if (result.status === 'suspended') {
// finalizeRun only fires on terminal outcomes; record suspended-segment usage here.
@@ -4206,7 +4131,6 @@ export class InstanceAiService {
threadId,
runId,
abortController,
- snapshotStorage,
tracing,
});
return;
@@ -4217,10 +4141,6 @@ export class InstanceAiService {
this.eventBus.publish(threadId, result.confirmationEvent);
}
- // Persist the agent tree so the confirmation UI survives page refresh.
- // The tree is rebuilt from in-memory events and includes the
- // confirmation-request data that the frontend needs.
- await this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
const suspensionOutputs = buildSuspensionTraceOutputs(runId, result.suspension);
await this.tracing.finalizeRunTracing(runId, tracing, {
status: 'suspended',
@@ -4331,7 +4251,7 @@ export class InstanceAiService {
aiCreatedWorkflowIds,
this.backgroundTasks.getRunningTasks(threadId).length,
);
- await this.finalizeRun(threadId, runId, result.status, snapshotStorage, {
+ await this.finalizeRun(threadId, runId, result.status, {
userId: user.id,
modelId,
archivedWorkflowIds,
@@ -4421,9 +4341,6 @@ export class InstanceAiService {
archivedWorkflowIds,
user.id,
);
- if (activeSnapshotStorage) {
- await this.saveAgentTreeSnapshot(threadId, runId, activeSnapshotStorage);
- }
return;
}
@@ -4497,9 +4414,6 @@ export class InstanceAiService {
user.id,
{ errorMessage, errorSource: 'exception' },
);
- if (activeSnapshotStorage) {
- await this.saveAgentTreeSnapshot(threadId, runId, activeSnapshotStorage);
- }
} finally {
this.runState.clearActiveRun(threadId);
const segmentSuspended = messageTraceFinalization?.status === 'suspended';
@@ -5337,7 +5251,6 @@ export class InstanceAiService {
errorInfo: { errorMessage: rebuildFailure, errorSource: 'exception' },
messageGroupId,
user: activeUser,
- snapshotStorage: this.dbSnapshotStorage,
});
this.runState.clearActiveRun(threadId, resumeExecutionToken);
return null;
@@ -5357,7 +5270,6 @@ export class InstanceAiService {
suspendPayload,
signal: abortController.signal,
abortController,
- snapshotStorage: this.dbSnapshotStorage,
tracing: effectiveTracing,
orchestrationContext: resumeOrchestrationContext,
modelId: resumeModelId,
@@ -5391,7 +5303,6 @@ export class InstanceAiService {
suspendPayload?: Record;
signal: AbortSignal;
abortController: AbortController;
- snapshotStorage: DbSnapshotStorage;
tracing?: InstanceAiTraceContext;
orchestrationContext?: OrchestrationContext;
modelId?: ModelConfig;
@@ -5489,7 +5400,6 @@ export class InstanceAiService {
agentRunId: opts.agentRunId,
onActivity: () => this.runState.touchActiveRun(opts.threadId),
stopSignal,
- outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
})
: await resumeAgentRun(agent, resumeData, resumeOptions, {
@@ -5502,7 +5412,6 @@ export class InstanceAiService {
agentRunId: opts.agentRunId,
onActivity: () => this.runState.touchActiveRun(opts.threadId),
stopSignal,
- outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
if (!resumeClaimed) {
skipPostRunCleanup = true;
@@ -5591,7 +5500,6 @@ export class InstanceAiService {
threadId: opts.threadId,
runId: opts.runId,
abortController: opts.abortController,
- snapshotStorage: opts.snapshotStorage,
tracing: opts.tracing,
});
return;
@@ -5602,9 +5510,6 @@ export class InstanceAiService {
this.eventBus.publish(opts.threadId, result.confirmationEvent);
}
- // Persist the refreshed agent tree so repeated HITL waits
- // survive page refresh after a resume as well.
- await this.saveAgentTreeSnapshot(opts.threadId, opts.runId, opts.snapshotStorage);
const suspensionOutputs = buildSuspensionTraceOutputs(opts.runId, result.suspension);
await this.tracing.finalizeRunTracing(opts.runId, opts.tracing, {
status: 'suspended',
@@ -5718,7 +5623,7 @@ export class InstanceAiService {
undefined,
this.backgroundTasks.getRunningTasks(opts.threadId).length,
);
- await this.finalizeRun(opts.threadId, opts.runId, result.status, opts.snapshotStorage, {
+ await this.finalizeRun(opts.threadId, opts.runId, result.status, {
userId: opts.user.id,
// Forward modelId so title refinement fires on the resume path too — a run
// that suspends for HITL and completes here would otherwise never be titled.
@@ -5815,7 +5720,6 @@ export class InstanceAiService {
archivedWorkflowIds,
opts.user.id,
);
- await this.saveAgentTreeSnapshot(opts.threadId, opts.runId, opts.snapshotStorage);
return;
}
@@ -5892,7 +5796,6 @@ export class InstanceAiService {
opts.user.id,
{ errorMessage, errorSource: 'exception' },
);
- await this.saveAgentTreeSnapshot(opts.threadId, opts.runId, opts.snapshotStorage);
} finally {
this.runState.clearActiveRun(opts.threadId, opts.resumeExecutionToken);
const segmentSuspended = messageTraceFinalization?.status === 'suspended';
@@ -6008,7 +5911,6 @@ export class InstanceAiService {
reason,
messageGroupId: opts.messageGroupId,
user: opts.user,
- snapshotStorage: opts.snapshotStorage,
});
return;
}
@@ -6043,7 +5945,6 @@ export class InstanceAiService {
// message group has to come from the suspended run, not the trace registry.
messageGroupId: opts.messageGroupId,
user: opts.user,
- snapshotStorage: opts.snapshotStorage,
});
return;
@@ -6055,8 +5956,7 @@ export class InstanceAiService {
/**
* Terminalizes a run that ended in a stop or a failure. `publishRunFinish` is
* the one step that has to land — without it the chat hangs forever — so every
- * DB-touching step around it is best-effort. `saveAgentTreeSnapshot` rebuilds
- * the agent tree by folding the event bus, so it has to come last.
+ * DB-touching step around it is best-effort.
*/
private async emitTerminalRun(args: {
threadId: string;
@@ -6067,7 +5967,6 @@ export class InstanceAiService {
errorInfo?: RunFinishErrorInfo;
messageGroupId?: string;
user: User;
- snapshotStorage: DbSnapshotStorage;
}): Promise {
const { threadId, runId, status } = args;
const context = { threadId, runId };
@@ -6104,12 +6003,6 @@ export class InstanceAiService {
args.user.id,
args.errorInfo,
);
-
- await this.bestEffort(
- 'Failed to save the agent tree snapshot for a settling run',
- context,
- async () => await this.saveAgentTreeSnapshot(threadId, runId, args.snapshotStorage),
- );
}
private async bestEffort(
@@ -6130,7 +6023,6 @@ export class InstanceAiService {
private spawnBackgroundTask(
runId: string,
opts: SpawnBackgroundTaskOptions,
- snapshotStorage: DbSnapshotStorage,
messageGroupIdOverride?: string,
): SpawnBackgroundTaskResult {
const outcome = this.backgroundTasks.spawn({
@@ -6215,13 +6107,6 @@ export class InstanceAiService {
},
onSettled: async (task) => {
await this.terminalOutcome.recordBackgroundTerminalOutcome(task);
- await this.saveAgentTreeSnapshot(
- opts.threadId,
- runId,
- snapshotStorage,
- true,
- task.messageGroupId,
- );
// Auto-follow-up: when the last background task finishes and no
// orchestrator run is active, resume the orchestrator so it can
@@ -6536,14 +6421,6 @@ export class InstanceAiService {
suspended.user.id,
);
- // Persist the snapshot so the run-finish event (which clears
- // in-flight tool calls) is reflected in the stored tree.
- await this.saveAgentTreeSnapshot(
- suspended.threadId,
- suspended.runId,
- this.dbSnapshotStorage,
- true,
- );
await this.tracing.maybeFinalizeRunTraceRoot(suspended.runId, {
status: 'cancelled',
reason,
@@ -6712,7 +6589,6 @@ export class InstanceAiService {
threadId: string,
runId: string,
status: 'completed' | 'cancelled' | 'errored',
- snapshotStorage: DbSnapshotStorage,
options?: {
userId?: string;
modelId?: ModelConfig;
@@ -6733,7 +6609,6 @@ export class InstanceAiService {
options?.errorInfo,
);
this.emitRunMetrics(threadId, status, options);
- await this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
if (status === 'completed' && options?.userId && options?.modelId) {
void this.refineTitleIfNeeded(threadId, options.userId, options.modelId);
}
@@ -6890,70 +6765,6 @@ export class InstanceAiService {
return await this.eventLog.getEventsForRuns(threadId, runIds);
}
- /**
- * Build an agent tree from in-memory events and persist it as a thread metadata snapshot.
- * @param isUpdate If true, updates the existing snapshot for this runId (background task completion).
- */
- private async saveAgentTreeSnapshot(
- threadId: string,
- runId: string,
- snapshotStorage: DbSnapshotStorage,
- isUpdate = false,
- overrideMessageGroupId?: string,
- ): Promise {
- try {
- const messageGroupId = overrideMessageGroupId ?? this.runState.getMessageGroupId(threadId);
-
- let events: InstanceAiEvent[];
- let groupRunIds: string[] | undefined;
- if (messageGroupId) {
- groupRunIds = this.getRunIdsForMessageGroup(messageGroupId);
- if (groupRunIds.length === 0) {
- const snapshot = await snapshotStorage.getLatest(threadId, { messageGroupId, runId });
- groupRunIds = snapshot?.runIds?.length ? snapshot.runIds : [runId];
- }
- events = await this.readRunEvents(threadId, groupRunIds);
- } else {
- events = await this.readRunEvents(threadId, [runId]);
- }
- // The tree input comes from the DB, so long runs cannot out-evict their
- // own snapshot input (the empty-agentTree bug class). The snapshot write
- // itself stays for now so pre-log threads keep rendering; history moves
- // to fold-on-read separately.
- if (isUpdate && events.length === 0) {
- this.logger.warn('Skipped updating empty Instance AI agent tree snapshot', {
- threadId,
- runId,
- messageGroupId,
- });
- return;
- }
- const agentTree = buildAgentTreeFromEvents(events);
-
- const tracing = this.tracing.getTraceContext(runId);
- const saveOptions = {
- messageGroupId,
- runIds: groupRunIds,
- traceId: tracing?.rootRun.otelTraceId,
- spanId: tracing?.rootRun.otelSpanId,
- langsmithRunId: tracing?.rootRun.id,
- langsmithTraceId: tracing?.rootRun.traceId,
- };
-
- if (isUpdate) {
- await snapshotStorage.updateLast(threadId, agentTree, runId, saveOptions);
- } else {
- await snapshotStorage.save(threadId, agentTree, runId, saveOptions);
- }
- } catch (error) {
- this.logger.warn('Failed to save agent tree snapshot', {
- threadId,
- runId,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
-
private parseMcpServers(raw: string): McpServerConfig[] {
if (!raw.trim()) return [];
diff --git a/packages/cli/src/modules/instance-ai/message-parser.ts b/packages/cli/src/modules/instance-ai/message-parser.ts
index f6b5e5015d5..94ad34dd595 100644
--- a/packages/cli/src/modules/instance-ai/message-parser.ts
+++ b/packages/cli/src/modules/instance-ai/message-parser.ts
@@ -1,12 +1,10 @@
-import { getRenderHint, normalizeAgentTree } from '@n8n/api-types';
+import { normalizeAgentTree } from '@n8n/api-types';
import type {
InstanceAiMessage,
InstanceAiAgentNode,
InstanceAiToolCallState,
- InstanceAiTimelineEntry,
} from '@n8n/api-types';
-import { orchestratorAgentId } from '@n8n/instance-ai';
-import type { AgentDbMessage, AgentTreeSnapshot, MessageContent } from '@n8n/instance-ai';
+import type { AgentDbMessage, AgentTreeSnapshot } from '@n8n/instance-ai';
import { z } from 'zod';
import {
@@ -17,44 +15,13 @@ import {
type RunSnapshots = AgentTreeSnapshot[];
-const toolCallContentPartSchema = z.object({
- type: z.literal('tool-call'),
- toolCallId: z.string(),
- toolName: z.string(),
- input: z.unknown().optional(),
- state: z.enum(['pending', 'resolved', 'rejected']).optional(),
- output: z.unknown().optional(),
- error: z.string().optional(),
-});
-
const textContentPartSchema = z.object({ type: z.literal('text'), text: z.string() });
const reasoningContentPartSchema = z.object({ type: z.literal('reasoning'), text: z.string() });
-const opaqueContentPartSchema = z
- .object({ type: z.enum(['invalid-tool-call', 'file', 'citation', 'provider']) })
- .passthrough();
-
-const contentPartSchema = z.union([
- textContentPartSchema,
- reasoningContentPartSchema,
- toolCallContentPartSchema,
- opaqueContentPartSchema,
-]);
// ---------------------------------------------------------------------------
// Persisted message shapes
// ---------------------------------------------------------------------------
-interface StoredToolInvocation {
- state: 'result' | 'call' | 'partial-call';
- toolCallId: string;
- toolName: string;
- args: Record;
- result?: unknown;
- error?: string;
-}
-
-type StoredContentPart = MessageContent;
-
export interface StoredAgentMessage {
id: string;
role: string;
@@ -104,187 +71,10 @@ function extractReasoningFromParts(parts: unknown[]): string {
.join('');
}
-function extractParts(content: unknown): StoredContentPart[] | undefined {
- if (Array.isArray(content)) return content.filter(isStoredContentPart);
- return undefined;
-}
-
-function isStoredContentPart(value: unknown): value is StoredContentPart {
- return contentPartSchema.safeParse(value).success;
-}
-
-function toRecord(value: unknown): Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
- ? (value as Record)
- : {};
-}
-
-function nativeToolPartToInvocation(part: StoredContentPart): StoredToolInvocation | undefined {
- if (part.type !== 'tool-call') return undefined;
-
- const parsed = toolCallContentPartSchema.safeParse(part);
- if (!parsed.success) return undefined;
- const toolCall = parsed.data;
-
- const args = toRecord(toolCall.input);
- if (toolCall.state === 'resolved') {
- return {
- state: 'result',
- toolCallId: toolCall.toolCallId,
- toolName: toolCall.toolName,
- args,
- result: toolCall.output,
- };
- }
- if (toolCall.state === 'rejected') {
- return {
- state: 'result',
- toolCallId: toolCall.toolCallId,
- toolName: toolCall.toolName,
- args,
- error: toolCall.error,
- };
- }
- return {
- state: 'call',
- toolCallId: toolCall.toolCallId,
- toolName: toolCall.toolName,
- args,
- };
-}
-
-function extractToolInvocations(content: unknown): StoredToolInvocation[] {
- if (typeof content === 'string') return [];
- if (Array.isArray(content))
- return content.filter(isStoredContentPart).flatMap((part) => {
- const invocation = nativeToolPartToInvocation(part);
- return invocation ? [invocation] : [];
- });
- return [];
-}
-
-/**
- * Coarse per-row timing for reconstructed tool calls: the interval from the
- * previous stored message to this one brackets everything the response did.
- * Real per-call timestamps only exist in run snapshots; this approximation
- * lets a snapshot-less reload still show "Thought for Xs" (derived from
- * min-start/max-end across a thinking block) instead of a bare fallback.
- * Known coarseness: every call in a row shares the bracket, and an HITL pause
- * between rows counts as thinking time.
- */
-interface RowTiming {
- startedAt: string;
- completedAt: string;
-}
-
-function buildToolCallState(
- invocation: StoredToolInvocation,
- timing?: RowTiming,
-): InstanceAiToolCallState {
- const isCompleted = invocation.state === 'result';
- return {
- toolCallId: invocation.toolCallId,
- toolName: invocation.toolName,
- args: invocation.args,
- result: isCompleted ? invocation.result : undefined,
- error: isCompleted ? invocation.error : undefined,
- isLoading: !isCompleted,
- renderHint: getRenderHint(invocation.toolName),
- ...(timing ? { startedAt: timing.startedAt } : {}),
- ...(timing && isCompleted ? { completedAt: timing.completedAt } : {}),
- };
-}
-
-/**
- * Build a chronological timeline from native parts (preserves reasoning vs
- * tool-call vs text ordering). Falls back to a reasoning-first,
- * tool-calls-next heuristic when parts aren't available.
- *
- * `responseId` is a synthetic per-message id: each stored assistant row is one
- * LLM response, and the frontend needs response grouping to tell intermediate
- * narration (trace content follows in the same response) from final answers.
- * Without it, a reconstructed timeline renders every narration text outside
- * the thinking blocks, splitting them.
- */
-function buildTimeline(
- textContent: string,
- reasoning: string,
- toolCalls: InstanceAiToolCallState[],
- parts?: StoredContentPart[],
- responseId?: string,
-): InstanceAiTimelineEntry[] {
- const responseRef = responseId ? { responseId } : {};
-
- // If parts are available, use their ordering (chronologically accurate)
- if (parts?.length) {
- const timeline: InstanceAiTimelineEntry[] = [];
- for (const part of parts) {
- if (part.type === 'text' && part.text) {
- timeline.push({ type: 'text', content: part.text, ...responseRef });
- } else if (part.type === 'reasoning' && part.text) {
- timeline.push({ type: 'reasoning', content: part.text, ...responseRef });
- } else if (part.type === 'tool-call' && part.toolCallId) {
- timeline.push({ type: 'tool-call', toolCallId: part.toolCallId, ...responseRef });
- }
- }
- return timeline;
- }
-
- // No parts — heuristic: reasoning first, then tool calls, then text
- // (most common agent pattern)
- const timeline: InstanceAiTimelineEntry[] = [];
- if (reasoning) {
- timeline.push({ type: 'reasoning', content: reasoning, ...responseRef });
- }
- for (const tc of toolCalls) {
- timeline.push({ type: 'tool-call', toolCallId: tc.toolCallId, ...responseRef });
- }
- if (textContent) {
- timeline.push({ type: 'text', content: textContent, ...responseRef });
- }
- return timeline;
-}
-
-/**
- * Build a flat agent tree (orchestrator only) from tool invocations.
- * Used when no snapshot is available, or when falling back from a degenerate one.
- * `status` is inherited from the snapshot so a `cancelled` run still reads as cancelled.
- *
- * A reconstructed tree is always historical — there is no live stream feeding it — so a
- * non-terminal status is normalized to `completed` (a mid-run snapshot must not render as
- * busy forever), and any tool call left loading on a stopped run is settled, mirroring the
- * live `run-finish` reducer so a cancelled bubble doesn't show a spinner that never resolves.
- */
-function buildFlatAgentTree(
- runId: string,
- textContent: string,
- reasoning: string,
- toolCalls: InstanceAiToolCallState[],
- parts?: StoredContentPart[],
- status: InstanceAiAgentNode['status'] = 'completed',
- responseId?: string,
-): InstanceAiAgentNode {
- const resolvedStatus = status === 'active' ? 'completed' : status;
- const settledToolCalls =
- resolvedStatus === 'cancelled' || resolvedStatus === 'error'
- ? toolCalls.map((tc) => (tc.isLoading ? { ...tc, isLoading: false } : tc))
- : toolCalls;
- return {
- agentId: orchestratorAgentId(runId),
- role: 'orchestrator',
- status: resolvedStatus,
- textContent,
- reasoning,
- toolCalls: settledToolCalls,
- children: [],
- timeline: buildTimeline(textContent, reasoning, settledToolCalls, parts, responseId),
- };
-}
-
/**
* Whether a snapshot tree carries anything worth rendering. An empty terminal tree —
* e.g. a `cancelled` run whose events were lost before the
- * snapshot was built — has none of these, so the message-derived flat tree is preferred.
+ * snapshot was built — has none of these, so the message renders without a tree.
*/
function isRenderableTree(tree: InstanceAiAgentNode): boolean {
return (
@@ -302,25 +92,6 @@ function isRenderableTree(tree: InstanceAiAgentNode): boolean {
);
}
-/**
- * Merge an earlier flat orchestrator tree into a later one (earlier content first).
- * Aggregates the assistant rows of a turn whose snapshot is empty so the whole turn's
- * orchestrator activity renders as one bubble after the dedup collapse, instead of
- * keeping only the last row.
- */
-function mergeFlatAgentTrees(
- earlier: InstanceAiAgentNode,
- later: InstanceAiAgentNode,
-): InstanceAiAgentNode {
- return {
- ...later,
- textContent: [earlier.textContent, later.textContent].filter(Boolean).join('\n\n'),
- reasoning: [earlier.reasoning, later.reasoning].filter(Boolean).join('\n\n'),
- toolCalls: [...earlier.toolCalls, ...later.toolCalls],
- timeline: [...earlier.timeline, ...later.timeline],
- };
-}
-
function snapshotTimestamp(snapshot: AgentTreeSnapshot): string {
return (snapshot.updatedAt ?? snapshot.createdAt ?? new Date(0)).toISOString();
}
@@ -364,16 +135,11 @@ function buildSnapshotMessage(snapshot: AgentTreeSnapshot): InstanceAiMessage {
// Main parser
// ---------------------------------------------------------------------------
-/**
- * Durable-log instrumentation: counts assistant messages that rendered from
- * the message-derived fallback ladder instead of a renderable snapshot tree.
- * Forwarded to the metrics pipeline via DurableLogMetrics.notifyParserFallbacks.
- */
-export const messageParserStats = { fallbackActivations: 0 };
-
/**
* Converts persisted native agent messages into rich InstanceAiMessage objects
- * with agent trees (from snapshots or reconstructed flat trees).
+ * with agent trees folded from the durable event log. A message whose run left
+ * no log rows (eval-seeded threads, pre-log dev instances) renders from its
+ * `content`/`reasoning` fields without a tree.
*/
export function parseStoredMessages(
storedMessages: Array,
@@ -390,18 +156,16 @@ export function parseStoredMessages(
// orphan snapshots before, between, or after assistant rows.
let nextSnapshotIdx = 0;
const consumedSnapshots = new Set();
- // Messages whose `agentTree` originated from a snapshot (as opposed to
- // being synthesized by `buildFlatAgentTree`). Used by the dedupe pass to
- // prefer transferring snapshot trees forward in the in-flight HITL case.
+ // Messages whose `agentTree` is a renderable snapshot tree. Used by the
+ // dedupe pass to transfer snapshot trees forward in the in-flight HITL case.
const messagesWithSnapshotTree = new Set();
let lastUserMessageId: string | undefined;
function pushSnapshotMessage(snapshot: AgentTreeSnapshot): void {
const built = buildSnapshotMessage(snapshot);
- // A degenerate (empty) orphan snapshot must not count as authoritative: when the
- // turn also has message rows, their reconstructed flat tree must win the dedup
- // collapse instead of this empty tree clobbering it. Mirrors the paired-row guard.
+ // A degenerate (empty) orphan snapshot must not count as authoritative in
+ // the dedup collapse. Mirrors the paired-row guard.
if (isRenderableTree(snapshot.tree)) messagesWithSnapshotTree.add(built);
messages.push(built);
}
@@ -476,52 +240,17 @@ export function parseStoredMessages(
if (msg.role === 'assistant') {
const reasoning = extractReasoningFromContent(msg.content);
- const invocations = extractToolInvocations(msg.content);
- const prevMessage = messageIndex > 0 ? conversationMessages[messageIndex - 1] : undefined;
- const timing: RowTiming | undefined = prevMessage
- ? {
- startedAt: prevMessage.createdAt.toISOString(),
- completedAt: msg.createdAt.toISOString(),
- }
- : undefined;
- const toolCalls = invocations.map((invocation) => buildToolCallState(invocation, timing));
- const parts = extractParts(msg.content);
const snapshot = takeSnapshotForAssistant(msg, messageIndex);
// Use the native runId from the snapshot (matches SSE events),
// falling back to the user-message ID if no snapshot exists.
const runId = snapshot?.runId ?? lastUserMessageId ?? msg.id;
- // The message id doubles as the synthetic responseId: one stored
- // assistant row = one LLM response, and unlike `runId` (which falls
- // back to the user-message id shared by the whole turn) it is unique
- // per row, so response grouping survives the flat-tree merge.
- const messageFlatTree =
- toolCalls.length > 0 || text || reasoning
- ? buildFlatAgentTree(
- runId,
- text,
- reasoning,
- toolCalls,
- parts,
- snapshot?.tree.status,
- msg.id,
- )
- : undefined;
- // Carry the cancellation cause onto the fallback tree so a stopped run is
- // still attributable (user/timeout/shutdown) after the snapshot was lost.
- if (messageFlatTree && snapshot?.tree.cancellationReason) {
- messageFlatTree.cancellationReason = snapshot.tree.cancellationReason;
- }
- // Prefer the snapshot tree, but when it carries no renderable content (e.g. an
- // empty `cancelled` tree from a run whose events were lost before the snapshot
- // was built) fall back to the message-derived flat tree so the turn's work still
- // renders on reload. A non-renderable snapshot is never authoritative — if there
- // is no flat tree either, leave the tree undefined rather than re-admitting the
- // empty one.
+ // A non-renderable tree (e.g. an empty `cancelled` tree from a run whose
+ // events were lost) is never authoritative — leave the tree undefined and
+ // let the message render from its own content instead.
const snapshotIsRenderable = snapshot !== undefined && isRenderableTree(snapshot.tree);
- const agentTree = snapshotIsRenderable ? snapshot.tree : messageFlatTree;
- if (!snapshotIsRenderable && messageFlatTree) messageParserStats.fallbackActivations++;
+ const agentTree = snapshotIsRenderable ? snapshot.tree : undefined;
const assistantMessage: InstanceAiMessage = {
id: msg.id,
@@ -570,10 +299,9 @@ export function parseStoredMessages(
// Follow-up runs in the same group produce separate DB rows; keep only
// the latest (which carries the full runIds array and complete tree).
//
- // In-flight HITL turns are different: the snapshot is paired with a
- // *middle* checkpoint message via timestamp matching, and the latest
- // message in the turn has only an auto-generated flat tree from
- // `buildFlatAgentTree`. Keeping just the latest would drop the
+ // In-flight HITL turns are different: the snapshot can pair with a
+ // *middle* row of the turn via timestamp matching, leaving the latest
+ // message without a tree. Keeping just the latest would drop the
// snapshot's tree (including its live confirmation cards), so transfer
// the snapshot's `agentTree` + `runIds` onto the kept message when the
// kept one's tree didn't come from a snapshot.
@@ -589,19 +317,10 @@ export function parseStoredMessages(
}
const kept = messages[keptIdx];
const candidate = messages[i];
- if (!messagesWithSnapshotTree.has(kept)) {
- if (messagesWithSnapshotTree.has(candidate)) {
- kept.agentTree = candidate.agentTree;
- kept.runIds = candidate.runIds;
- messagesWithSnapshotTree.add(kept);
- } else if (candidate.agentTree) {
- // Neither row is snapshot-backed (degenerate-snapshot turn): aggregate the
- // earlier row's flat-tree activity into the kept bubble so the whole turn's
- // orchestrator work survives the collapse instead of just the last row.
- kept.agentTree = kept.agentTree
- ? mergeFlatAgentTrees(candidate.agentTree, kept.agentTree)
- : candidate.agentTree;
- }
+ if (!messagesWithSnapshotTree.has(kept) && messagesWithSnapshotTree.has(candidate)) {
+ kept.agentTree = candidate.agentTree;
+ kept.runIds = candidate.runIds;
+ messagesWithSnapshotTree.add(kept);
}
toRemove.add(i);
}
diff --git a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts
index 9a559397d0f..1f7e1010f00 100644
--- a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts
+++ b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts
@@ -88,6 +88,80 @@ describe('InstanceAiEventLogRepository', () => {
});
});
+ describe('findLangsmithAnchor', () => {
+ const runStartRow = (
+ seq: number,
+ runId: string,
+ payload: Record,
+ ): InstanceAiEventLogEntry =>
+ ({
+ seq,
+ runId,
+ createdAt: new Date(`2026-07-01T10:00:0${seq}.000Z`),
+ payload: JSON.stringify({ type: 'run-start', runId, agentId: 'a1', payload }),
+ }) as InstanceAiEventLogEntry;
+
+ const repoWithRunStarts = (rows: InstanceAiEventLogEntry[]) => {
+ const repo = Object.create(
+ InstanceAiEventLogRepository.prototype,
+ ) as InstanceAiEventLogRepository;
+ const find = vi.fn().mockResolvedValue(rows);
+ Object.defineProperty(repo, 'find', { value: find, configurable: true });
+ return { repo, find };
+ };
+
+ it('resolves the group anchor from a later sibling when earlier ones carry no ids', async () => {
+ const { repo, find } = repoWithRunStarts([
+ // Anchored, but a different group: must never hijack another group's turn.
+ runStartRow(1, 'run-0', {
+ messageGroupId: 'mg-other',
+ langsmithRunId: 'ls-other',
+ langsmithTraceId: 'trace-other',
+ }),
+ // The group's first sibling, unanchored — what a segment without
+ // tracing leaves behind.
+ runStartRow(2, 'run-1', { messageGroupId: 'mg-1' }),
+ runStartRow(3, 'run-2', {
+ messageGroupId: 'mg-1',
+ langsmithRunId: 'ls-run',
+ langsmithTraceId: 'ls-trace',
+ }),
+ ]);
+
+ await expect(repo.findLangsmithAnchor('thread-1', 'mg-1')).resolves.toEqual({
+ langsmithRunId: 'ls-run',
+ langsmithTraceId: 'ls-trace',
+ });
+ // One row per run, not the whole log.
+ expect(find).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { threadId: 'thread-1', type: 'run-start' } }),
+ );
+ });
+
+ it('falls back to the runId for a turn with no message group', async () => {
+ const { repo } = repoWithRunStarts([
+ runStartRow(1, 'run-1', { langsmithRunId: 'ls-run', langsmithTraceId: 'ls-trace' }),
+ ]);
+
+ await expect(repo.findLangsmithAnchor('thread-1', 'run-1')).resolves.toEqual({
+ langsmithRunId: 'ls-run',
+ langsmithTraceId: 'ls-trace',
+ });
+ });
+
+ it('resolves undefined for a genuinely untraced turn', async () => {
+ const { repo } = repoWithRunStarts([
+ runStartRow(1, 'run-1', { messageGroupId: 'mg-1' }),
+ runStartRow(2, 'run-2', { messageGroupId: 'mg-1' }),
+ ]);
+
+ // No sibling in the group is anchored…
+ await expect(repo.findLangsmithAnchor('thread-1', 'mg-1')).resolves.toBeUndefined();
+ // …and the runId fallback must not fabricate an anchor from an id-less start.
+ await expect(repo.findLangsmithAnchor('thread-1', 'run-2')).resolves.toBeUndefined();
+ });
+ });
+
describe('findRunIdsInWindow', () => {
it('bounds the window half-open so the next page owns its first fact', async () => {
const repo = Object.create(
diff --git a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts
deleted file mode 100644
index 60044d1bf12..00000000000
--- a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import type { FindManyOptions, FindOperator } from '@n8n/typeorm';
-
-import type { InstanceAiRunSnapshot } from '../../entities/instance-ai-run-snapshot.entity';
-import { InstanceAiRunSnapshotRepository } from '../instance-ai-run-snapshot.repository';
-
-/** The `createdAt` operator `findInWindow` built, as {type, value}. */
-function createdAtOperator(find: ReturnType) {
- const options = find.mock.calls[0][0] as FindManyOptions;
- const where = options.where as { createdAt?: FindOperator };
- const operator = where.createdAt;
- if (!operator) return undefined;
- return { type: operator.type, value: operator.value };
-}
-
-describe('InstanceAiRunSnapshotRepository.findInWindow', () => {
- const since = new Date('2026-01-01T00:00:00.000Z');
- const before = new Date('2026-01-02T00:00:00.000Z');
-
- function createRepo() {
- const repo = Object.create(
- InstanceAiRunSnapshotRepository.prototype,
- ) as InstanceAiRunSnapshotRepository;
- const find = vi.fn().mockResolvedValue([]);
- Object.defineProperty(repo, 'find', { value: find, configurable: true });
- return { repo, find };
- }
-
- it('bounds both sides when the window is closed, upper bound exclusive', async () => {
- // Half-open: the next page owns a snapshot written exactly at the
- // boundary, so no snapshot is claimed by two pages.
- const { repo, find } = createRepo();
-
- await repo.findInWindow('thread-1', { since, before });
-
- expect(createdAtOperator(find)).toEqual({
- type: 'and',
- value: [
- expect.objectContaining({ type: 'moreThanOrEqual', value: since }),
- expect.objectContaining({ type: 'lessThan', value: before }),
- ],
- });
- });
-
- it('bounds only the lower side for the newest page', async () => {
- const { repo, find } = createRepo();
-
- await repo.findInWindow('thread-1', { since });
-
- expect(createdAtOperator(find)).toEqual({ type: 'moreThanOrEqual', value: since });
- });
-
- it('bounds only the upper side when there is no lower bound', async () => {
- const { repo, find } = createRepo();
-
- await repo.findInWindow('thread-1', { before });
-
- expect(createdAtOperator(find)).toEqual({ type: 'lessThan', value: before });
- });
-
- it('reads the whole thread for an open window', async () => {
- const { repo, find } = createRepo();
-
- await repo.findInWindow('thread-1', {});
-
- expect(createdAtOperator(find)).toBeUndefined();
- expect(find).toHaveBeenCalledWith(expect.objectContaining({ where: { threadId: 'thread-1' } }));
- });
-});
diff --git a/packages/cli/src/modules/instance-ai/repositories/index.ts b/packages/cli/src/modules/instance-ai/repositories/index.ts
index c62999775ea..55a2a275bcf 100644
--- a/packages/cli/src/modules/instance-ai/repositories/index.ts
+++ b/packages/cli/src/modules/instance-ai/repositories/index.ts
@@ -1,7 +1,6 @@
export { InstanceAiThreadRepository } from './instance-ai-thread.repository';
export { InstanceAiMessageRepository } from './instance-ai-message.repository';
export { InstanceAiResourceRepository } from './instance-ai-resource.repository';
-export { InstanceAiRunSnapshotRepository } from './instance-ai-run-snapshot.repository';
export { InstanceAiIterationLogRepository } from './instance-ai-iteration-log.repository';
export { InstanceAiCheckpointRepository } from './instance-ai-checkpoint.repository';
export { InstanceAiObservationRepository } from './instance-ai-observation.repository';
diff --git a/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts b/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts
index 7609a2b6ffb..c0e1f6c44c9 100644
--- a/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts
+++ b/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts
@@ -111,8 +111,7 @@ export class InstanceAiEventLogRepository extends Repository {
+ const rows = await this.find({
+ where: { threadId, type: 'run-start' },
+ order: { seq: 'ASC' },
+ });
+ const starts = rows.map((r) => this.toEvent(r));
+ const isAnchoredRunStart = (
+ e: InstanceAiEvent,
+ ): e is Extract =>
+ e.type === 'run-start' && Boolean(e.payload.langsmithRunId && e.payload.langsmithTraceId);
+ const byGroup = starts.find(
+ (e) => isAnchoredRunStart(e) && e.payload.messageGroupId === responseId,
+ );
+ const anchor = byGroup ?? starts.find((e) => e.runId === responseId);
+ if (anchor?.type !== 'run-start') return undefined;
+ const { langsmithRunId, langsmithTraceId } = anchor.payload;
+ if (!langsmithRunId || !langsmithTraceId) return undefined;
+ return { langsmithRunId, langsmithTraceId };
+ }
+
/** Timestamp of the run's most recent durable fact (sweep liveness proxy). */
async lastFactAt(threadId: string, runId: string): Promise {
const row = await this.createQueryBuilder('e')
diff --git a/packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts b/packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts
deleted file mode 100644
index 83659df52a4..00000000000
--- a/packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Service } from '@n8n/di';
-import { And, DataSource, LessThan, MoreThanOrEqual, Repository } from '@n8n/typeorm';
-
-import { InstanceAiRunSnapshot } from '../entities/instance-ai-run-snapshot.entity';
-
-@Service()
-export class InstanceAiRunSnapshotRepository extends Repository {
- constructor(dataSource: DataSource) {
- super(InstanceAiRunSnapshot, dataSource.manager);
- }
-
- /**
- * Snapshots written inside the half-open window `[since, before)`, oldest
- * first; an open bound means unbounded on that side. `(threadId, createdAt)`
- * is indexed, so this is a range scan and the `tree` column is only read for
- * the rows in the window.
- */
- async findInWindow(
- threadId: string,
- window: { since?: Date; before?: Date },
- ): Promise {
- const { since, before } = window;
- const createdAt =
- since && before
- ? And(MoreThanOrEqual(since), LessThan(before))
- : since
- ? MoreThanOrEqual(since)
- : before
- ? LessThan(before)
- : undefined;
- return await this.find({
- where: { threadId, ...(createdAt ? { createdAt } : {}) },
- order: { createdAt: 'ASC' },
- });
- }
-}
diff --git a/packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts b/packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts
deleted file mode 100644
index 7e0f79a0323..00000000000
--- a/packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { mock } from 'vitest-mock-extended';
-
-import type { InstanceAiRunSnapshot } from '../../entities/instance-ai-run-snapshot.entity';
-import type { InstanceAiRunSnapshotRepository } from '../../repositories/instance-ai-run-snapshot.repository';
-import { DbSnapshotStorage } from '../db-snapshot-storage';
-
-function makeRow(overrides: Partial = {}): InstanceAiRunSnapshot {
- return {
- threadId: 'thread-1',
- runId: 'run-1',
- messageGroupId: null,
- runIds: null,
- tree: JSON.stringify({ agentId: 'agent-root' }),
- traceId: null,
- spanId: null,
- langsmithRunId: null,
- langsmithTraceId: null,
- createdAt: new Date(),
- updatedAt: new Date(),
- ...overrides,
- } as InstanceAiRunSnapshot;
-}
-
-describe('DbSnapshotStorage', () => {
- const repo = mock();
- const storage = new DbSnapshotStorage(repo);
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- describe('findLangsmithAnchor', () => {
- it('returns anchor when messageGroupId matches a row with IDs', async () => {
- repo.findOne.mockResolvedValueOnce(
- makeRow({
- messageGroupId: 'mg-1',
- langsmithRunId: 'ls-run-1',
- langsmithTraceId: 'ls-trace-1',
- }),
- );
-
- const anchor = await storage.findLangsmithAnchor('thread-1', 'mg-1');
-
- expect(anchor).toEqual({ langsmithRunId: 'ls-run-1', langsmithTraceId: 'ls-trace-1' });
- expect(repo.findOne).toHaveBeenCalledWith({
- where: { threadId: 'thread-1', messageGroupId: 'mg-1' },
- order: { createdAt: 'ASC' },
- });
- });
-
- it('falls back to runId lookup when messageGroupId lookup misses', async () => {
- repo.findOne.mockResolvedValueOnce(null);
- repo.findOneBy.mockResolvedValueOnce(
- makeRow({
- runId: 'mg-1',
- langsmithRunId: 'ls-run-2',
- langsmithTraceId: 'ls-trace-2',
- }),
- );
-
- const anchor = await storage.findLangsmithAnchor('thread-1', 'mg-1');
-
- expect(anchor).toEqual({ langsmithRunId: 'ls-run-2', langsmithTraceId: 'ls-trace-2' });
- expect(repo.findOneBy).toHaveBeenCalledWith({ threadId: 'thread-1', runId: 'mg-1' });
- });
-
- it('returns undefined when row is found but langsmith IDs are null', async () => {
- repo.findOne.mockResolvedValueOnce(makeRow({ messageGroupId: 'mg-1' }));
-
- const anchor = await storage.findLangsmithAnchor('thread-1', 'mg-1');
-
- expect(anchor).toBeUndefined();
- });
-
- it('returns undefined when no row exists at all', async () => {
- repo.findOne.mockResolvedValueOnce(null);
- repo.findOneBy.mockResolvedValueOnce(null);
-
- const anchor = await storage.findLangsmithAnchor('thread-1', 'missing');
-
- expect(anchor).toBeUndefined();
- });
- });
-
- describe('save', () => {
- it('persists trace IDs via upsert', async () => {
- await storage.save('thread-1', { agentId: 'agent-root' } as never, 'run-1', {
- messageGroupId: 'mg-1',
- runIds: ['run-1'],
- traceId: '0123456789abcdef0123456789abcdef',
- spanId: '0123456789abcdef',
- langsmithRunId: 'ls-run-1',
- langsmithTraceId: 'ls-trace-1',
- });
-
- expect(repo.upsert).toHaveBeenCalledWith(
- expect.objectContaining({
- threadId: 'thread-1',
- runId: 'run-1',
- messageGroupId: 'mg-1',
- runIds: ['run-1'],
- traceId: '0123456789abcdef0123456789abcdef',
- spanId: '0123456789abcdef',
- langsmithRunId: 'ls-run-1',
- langsmithTraceId: 'ls-trace-1',
- }),
- ['threadId', 'runId'],
- );
- });
-
- it('writes nulls when trace IDs are absent', async () => {
- await storage.save('thread-1', { agentId: 'agent-root' } as never, 'run-1');
-
- expect(repo.upsert).toHaveBeenCalledWith(
- expect.objectContaining({
- traceId: null,
- spanId: null,
- langsmithRunId: null,
- langsmithTraceId: null,
- }),
- expect.anything(),
- );
- });
- });
-
- describe('updateLast', () => {
- it('preserves existing trace IDs when the caller does not supply new ones', async () => {
- const existing = makeRow({
- messageGroupId: 'mg-1',
- traceId: 'existing-trace',
- spanId: 'existing-span',
- langsmithRunId: 'ls-run-existing',
- langsmithTraceId: 'ls-trace-existing',
- });
- repo.findOne.mockResolvedValueOnce(existing);
-
- await storage.updateLast('thread-1', { agentId: 'updated' } as never, 'run-1', {
- messageGroupId: 'mg-1',
- });
-
- expect(repo.update).toHaveBeenCalledWith(
- { threadId: 'thread-1', runId: 'run-1' },
- expect.objectContaining({
- traceId: 'existing-trace',
- spanId: 'existing-span',
- langsmithRunId: 'ls-run-existing',
- langsmithTraceId: 'ls-trace-existing',
- }),
- );
- });
- });
-
- describe('getForWindow', () => {
- it('passes the window through and hydrates the rows it gets back', async () => {
- const since = new Date('2026-01-01T00:00:00.000Z');
- repo.findInWindow.mockResolvedValueOnce([
- makeRow({ runId: 'run-1', messageGroupId: 'mg-1', createdAt: since }),
- ]);
-
- const snapshots = await storage.getForWindow('thread-1', { since });
-
- expect(repo.findInWindow).toHaveBeenCalledWith('thread-1', { since });
- expect(snapshots).toEqual([
- expect.objectContaining({
- runId: 'run-1',
- messageGroupId: 'mg-1',
- tree: { agentId: 'agent-root' },
- }),
- ]);
- });
-
- it('defaults to the whole thread when no window is given', async () => {
- repo.findInWindow.mockResolvedValueOnce([]);
-
- await storage.getForWindow('thread-1');
-
- expect(repo.findInWindow).toHaveBeenCalledWith('thread-1', {});
- });
- });
-});
diff --git a/packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts b/packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts
deleted file mode 100644
index b407b32a5de..00000000000
--- a/packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import type { InstanceAiAgentNode } from '@n8n/api-types';
-import { Service } from '@n8n/di';
-import type { AgentTreeSnapshot } from '@n8n/instance-ai';
-import { jsonParse } from 'n8n-workflow';
-
-import { InstanceAiRunSnapshotRepository } from '../repositories/instance-ai-run-snapshot.repository';
-
-/**
- * Walk a saved agent tree and flip everything in-flight to a terminal state.
- * Active sub-agents become `cancelled`, loading tool calls stop loading, and
- * unresolved HITL confirmation cards get a `denied` status so the frontend
- * stops rendering Allow / Request-changes buttons. Mutates `node` in place.
- */
-function cancelInFlightNodes(node: InstanceAiAgentNode): void {
- if (node.status === 'active') node.status = 'cancelled';
- for (const call of node.toolCalls) {
- if (!call.isLoading) continue;
- call.isLoading = false;
- if (call.confirmation && !call.confirmationStatus) {
- call.confirmationStatus = 'denied';
- }
- }
- for (const child of node.children) cancelInFlightNodes(child);
-}
-
-export interface SaveSnapshotOptions {
- messageGroupId?: string;
- runIds?: string[];
- traceId?: string;
- spanId?: string;
- langsmithRunId?: string;
- langsmithTraceId?: string;
-}
-
-@Service()
-export class DbSnapshotStorage {
- constructor(private readonly repo: InstanceAiRunSnapshotRepository) {}
-
- async getLatest(
- threadId: string,
- options: { messageGroupId?: string; runId?: string } = {},
- ): Promise {
- const { messageGroupId, runId } = options;
-
- const row = messageGroupId
- ? await this.repo.findOne({
- where: { threadId, messageGroupId },
- order: { createdAt: 'DESC' },
- })
- : runId
- ? await this.repo.findOne({
- where: { threadId, runId },
- order: { createdAt: 'DESC' },
- })
- : await this.repo.findOne({
- where: { threadId },
- order: { createdAt: 'DESC' },
- });
-
- if (!row) return undefined;
-
- return {
- tree: jsonParse(row.tree),
- runId: row.runId,
- messageGroupId: row.messageGroupId ?? undefined,
- runIds: row.runIds ?? undefined,
- traceId: row.traceId ?? undefined,
- spanId: row.spanId ?? undefined,
- langsmithRunId: row.langsmithRunId ?? undefined,
- langsmithTraceId: row.langsmithTraceId ?? undefined,
- createdAt: row.createdAt,
- updatedAt: row.updatedAt,
- };
- }
-
- async save(
- threadId: string,
- agentTree: InstanceAiAgentNode,
- runId: string,
- options: SaveSnapshotOptions = {},
- ): Promise {
- const { messageGroupId, runIds, traceId, spanId, langsmithRunId, langsmithTraceId } = options;
- await this.repo.upsert(
- {
- threadId,
- runId,
- messageGroupId: messageGroupId ?? null,
- runIds: runIds ?? null,
- tree: JSON.stringify(agentTree),
- traceId: traceId ?? null,
- spanId: spanId ?? null,
- langsmithRunId: langsmithRunId ?? null,
- langsmithTraceId: langsmithTraceId ?? null,
- },
- ['threadId', 'runId'],
- );
- }
-
- async updateLast(
- threadId: string,
- agentTree: InstanceAiAgentNode,
- runId: string,
- options: SaveSnapshotOptions = {},
- ): Promise {
- const { messageGroupId, runIds, traceId, spanId, langsmithRunId, langsmithTraceId } = options;
-
- // Prefer lookup by messageGroupId when available
- if (messageGroupId) {
- const existing = await this.repo.findOne({
- where: { threadId, messageGroupId },
- order: { createdAt: 'DESC' },
- });
- if (existing) {
- await this.repo.update(
- { threadId: existing.threadId, runId: existing.runId },
- {
- runId,
- tree: JSON.stringify(agentTree),
- messageGroupId,
- runIds: runIds ?? existing.runIds,
- // Preserve existing trace IDs if caller didn't provide new ones.
- traceId: traceId ?? existing.traceId,
- spanId: spanId ?? existing.spanId,
- langsmithRunId: langsmithRunId ?? existing.langsmithRunId,
- langsmithTraceId: langsmithTraceId ?? existing.langsmithTraceId,
- },
- );
- return;
- }
- }
-
- // Fall back to runId lookup
- const byRunId = await this.repo.findOneBy({ threadId, runId });
- if (byRunId) {
- await this.repo.update(
- { threadId, runId },
- {
- tree: JSON.stringify(agentTree),
- messageGroupId: messageGroupId ?? byRunId.messageGroupId,
- runIds: runIds ?? byRunId.runIds,
- traceId: traceId ?? byRunId.traceId,
- spanId: spanId ?? byRunId.spanId,
- langsmithRunId: langsmithRunId ?? byRunId.langsmithRunId,
- langsmithTraceId: langsmithTraceId ?? byRunId.langsmithTraceId,
- },
- );
- return;
- }
-
- // No existing row — insert
- await this.save(threadId, agentTree, runId, options);
- }
-
- /**
- * Mark an existing snapshot as a cancelled run, terminalising every
- * `active` node and every in-flight tool call (including unresolved HITL
- * confirmations) in the saved tree. Used when a run is being marked
- * terminal after the in-memory event bus is gone — e.g. handling a
- * confirmation orphaned by a restart — because rebuilding the tree from
- * an empty bus would clobber the saved plan / ask card with an empty
- * cancelled tree. Keeps the tool calls and confirmation payload intact
- * so the user can still see what was being planned, just
- * without interactive buttons.
- */
- async markRunCancelled(threadId: string, runId: string): Promise {
- const key = { threadId, runId };
- const row = await this.repo.findOneBy(key);
- if (!row) return;
-
- const tree = jsonParse(row.tree);
- cancelInFlightNodes(tree);
- await this.repo.update(key, { tree: JSON.stringify(tree) });
- }
-
- /**
- * Snapshots written inside the half-open window `[since, before)`, oldest
- * first. The window is the span of the message page being rendered: a
- * snapshot outside it has no message to pair with, and `parseStoredMessages`
- * would surface it as a message of its own. Passing `{}` reads the whole
- * thread.
- *
- * `(threadId, createdAt)` is indexed, so the bounded read is a range scan
- * and the `tree` column is only parsed for the rows the page needs.
- */
- async getForWindow(
- threadId: string,
- window: { since?: Date; before?: Date } = {},
- ): Promise {
- const rows = await this.repo.findInWindow(threadId, window);
- return rows.map((r) => ({
- tree: jsonParse(r.tree),
- runId: r.runId,
- messageGroupId: r.messageGroupId ?? undefined,
- runIds: r.runIds ?? undefined,
- traceId: r.traceId ?? undefined,
- spanId: r.spanId ?? undefined,
- langsmithRunId: r.langsmithRunId ?? undefined,
- langsmithTraceId: r.langsmithTraceId ?? undefined,
- createdAt: r.createdAt,
- updatedAt: r.updatedAt,
- }));
- }
-
- /**
- * Resolve the LangSmith root-run anchor for a given responseId
- * (UI sends `messageGroupId ?? runId`). Prefers the earliest snapshot row
- * in a message group so feedback attaches to the `message_turn` root run.
- */
- async findLangsmithAnchor(
- threadId: string,
- responseId: string,
- ): Promise<{ langsmithRunId: string; langsmithTraceId: string } | undefined> {
- const byGroup = await this.repo.findOne({
- where: { threadId, messageGroupId: responseId },
- order: { createdAt: 'ASC' },
- });
- const row = byGroup ?? (await this.repo.findOneBy({ threadId, runId: responseId }));
- if (!row?.langsmithRunId || !row.langsmithTraceId) return undefined;
- return { langsmithRunId: row.langsmithRunId, langsmithTraceId: row.langsmithTraceId };
- }
-}
diff --git a/packages/cli/src/modules/instance-ai/storage/index.ts b/packages/cli/src/modules/instance-ai/storage/index.ts
index e19857941c8..00ade2a7cfb 100644
--- a/packages/cli/src/modules/instance-ai/storage/index.ts
+++ b/packages/cli/src/modules/instance-ai/storage/index.ts
@@ -1,4 +1,3 @@
-export { DbSnapshotStorage } from './db-snapshot-storage';
export { DbIterationLogStorage } from './db-iteration-log-storage';
export { TypeORMAgentCheckpointStore } from './typeorm-agent-checkpoint-store';
export { TypeORMAgentMemory } from './typeorm-agent-memory';
diff --git a/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts b/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts
index 0e61202b579..6fee0ca19c1 100644
--- a/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts
+++ b/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts
@@ -365,7 +365,7 @@ export class TypeORMAgentMemory
/**
* Delete every thread owned by `resourceId` (a user), the sub-agent threads
* spawned under those threads, and all of their working-memory resources.
- * Downstream rows (messages, checkpoints, run snapshots, iteration logs,
+ * Downstream rows (messages, checkpoints, event-log entries, iteration logs,
* grants, pending confirmations, observations) cascade via their `threadId`
* FK; resources have no FK to threads and are removed explicitly. Returns the
* number of owner threads deleted.
diff --git a/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts b/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts
index af1f06dcb5e..8aaa7ea282f 100644
--- a/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts
+++ b/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts
@@ -12,7 +12,6 @@ import { UserError } from 'n8n-workflow';
import type { InstanceAiPendingConfirmation } from './entities/instance-ai-pending-confirmation.entity';
import type { InProcessEventBus } from './event-bus/in-process-event-bus';
import type { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository';
-import type { DbSnapshotStorage } from './storage/db-snapshot-storage';
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
@@ -66,9 +65,6 @@ export type OrphanConfirmationStore = Pick, 'suspendRun' | 'hasLiveRun'>;
-/** The slice of snapshot storage the restorer uses to terminalise a snapshot. */
-export type RunSnapshotCanceller = Pick;
-
/** The slice of the event bus the restorer uses to drop a stale client card. */
export type RunFinishEventPublisher = Pick;
@@ -76,7 +72,6 @@ export interface SuspendedRunRestorerOptions {
logger: Logger;
pendingConfirmationRepo: OrphanConfirmationStore;
runState: SuspendedRunStateRegistry;
- dbSnapshotStorage: RunSnapshotCanceller;
eventBus: RunFinishEventPublisher;
rebuilder: SuspendedRunRebuilder;
}
@@ -101,8 +96,6 @@ export class SuspendedRunRestorer {
private readonly runState: SuspendedRunStateRegistry;
- private readonly dbSnapshotStorage: RunSnapshotCanceller;
-
private readonly eventBus: RunFinishEventPublisher;
private readonly rebuilder: SuspendedRunRebuilder;
@@ -111,7 +104,6 @@ export class SuspendedRunRestorer {
this.logger = options.logger;
this.pendingConfirmationRepo = options.pendingConfirmationRepo;
this.runState = options.runState;
- this.dbSnapshotStorage = options.dbSnapshotStorage;
this.eventBus = options.eventBus;
this.rebuilder = options.rebuilder;
}
@@ -179,26 +171,13 @@ export class SuspendedRunRestorer {
private finalizeUnresumableOrphan(orphan: ClaimedOrphan): void {
try {
- // Live SSE clients use this to drop their interactive card.
+ // Live SSE clients use this to drop their interactive card. History
+ // needs nothing else: the durable run-finish terminalises the folded
+ // tree, and the confirmation card renders expired because `claim()`
+ // consumed its row.
this.publishRunFinish(orphan.threadId, orphan.runId, 'restart_lost_confirmation');
- // Terminalise the existing snapshot in place instead of rebuilding
- // the tree from the in-memory event bus. After a restart the bus
- // only carries the run-finish we just emitted, so a rebuild would
- // replace the saved plan/ask card with an empty cancelled tree;
- // `markRunCancelled` keeps the plan content intact while flipping
- // all in-flight nodes and confirmation buttons off.
- void this.dbSnapshotStorage
- .markRunCancelled(orphan.threadId, orphan.runId)
- .catch((error: unknown) => {
- this.logger.warn('Failed to mark orphan snapshot as cancelled', {
- requestId: orphan.requestId,
- threadId: orphan.threadId,
- runId: orphan.runId,
- error: getErrorMessage(error),
- });
- });
} catch (error: unknown) {
- this.logger.warn('Failed to finalize orphaned confirmation snapshot', {
+ this.logger.warn('Failed to finalize orphaned confirmation', {
requestId: orphan.requestId,
error: getErrorMessage(error),
});
diff --git a/packages/cli/src/modules/instance-ai/tracing/index.ts b/packages/cli/src/modules/instance-ai/tracing/index.ts
index 3ea24518d24..817e345a5b8 100644
--- a/packages/cli/src/modules/instance-ai/tracing/index.ts
+++ b/packages/cli/src/modules/instance-ai/tracing/index.ts
@@ -3,8 +3,8 @@ export {
type InstanceAiTracingAiService,
type InstanceAiTracingEventReader,
type InstanceAiTracingRunState,
+ type InstanceAiTracingEventLog,
type InstanceAiTracingServiceOptions,
- type InstanceAiTracingSnapshotStorage,
type MessageTraceFinalization,
type OrchestratorResumeReason,
} from './instance-ai-tracing.service';
diff --git a/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts b/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts
index a135441025e..ac6f51568b5 100644
--- a/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts
+++ b/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts
@@ -24,7 +24,7 @@ import {
buildInstanceAiRunTraceMetadata,
type InstanceAiRunTraceMetadataOptions,
} from '../run-trace-metadata';
-import type { DbSnapshotStorage } from '../storage/db-snapshot-storage';
+import type { InstanceAiEventLogRepository } from '../repositories/instance-ai-event-log.repository';
import { TraceReplayState } from '../trace-replay-state';
// Stable UUID namespace for deterministic feedback IDs. Submitting the same
@@ -68,7 +68,7 @@ export type InstanceAiTracingEventReader = {
export type InstanceAiTracingRunState = Pick, 'attachTracing'>;
-export type InstanceAiTracingSnapshotStorage = Pick;
+export type InstanceAiTracingEventLog = Pick;
export type InstanceAiTracingAiService = Pick;
@@ -76,7 +76,7 @@ export type InstanceAiTracingServiceOptions = {
logger: Logger;
eventReader: InstanceAiTracingEventReader;
runState: InstanceAiTracingRunState;
- dbSnapshotStorage: InstanceAiTracingSnapshotStorage;
+ eventLog: InstanceAiTracingEventLog;
aiService: InstanceAiTracingAiService;
};
@@ -87,7 +87,7 @@ export type InstanceAiTracingServiceOptions = {
* ID that started an orchestration turn) and the test-only trace replay state.
* Responsible for creating resume trace contexts, finalizing message- and
* run-level trace roots, releasing trace clients, and submitting LangSmith user
- * feedback. Collaborators (run state, event bus, snapshot storage, AI service)
+ * feedback. Collaborators (run state, event bus, event log, AI service)
* are supplied via the options bag because the run-context registry it manages
* is process-local and not suitable for dependency injection.
*/
@@ -112,7 +112,7 @@ export class InstanceAiTracingService {
private readonly runState: InstanceAiTracingRunState;
- private readonly dbSnapshotStorage: InstanceAiTracingSnapshotStorage;
+ private readonly eventLog: InstanceAiTracingEventLog;
private readonly aiService: InstanceAiTracingAiService;
@@ -120,7 +120,7 @@ export class InstanceAiTracingService {
this.logger = options.logger;
this.eventReader = options.eventReader;
this.runState = options.runState;
- this.dbSnapshotStorage = options.dbSnapshotStorage;
+ this.eventLog = options.eventLog;
this.aiService = options.aiService;
}
@@ -490,7 +490,7 @@ export class InstanceAiTracingService {
responseId: string,
payload: { rating: 'up' | 'down'; comment?: string },
): Promise {
- const anchor = await this.dbSnapshotStorage.findLangsmithAnchor(threadId, responseId);
+ const anchor = await this.eventLog.findLangsmithAnchor(threadId, responseId);
if (!anchor) {
this.logger.debug('No LangSmith anchor for feedback; skipping annotation', {
threadId,