fix(mastra): stop thread-scoped working memory aborting the first turn (closes OSS-1122) (#6870)

## Problem

A starter scaffolded with `copilotkit init --framework mastra` and
connected to managed Intelligence starts, accepts a chat message, and
never answers. `POST /api/copilotkit/agent/default/run` still returns
200, so the abort is only visible in the server log:

```
Agent execution failed: Error: Thread c883919e-… not found
```

## Root cause

`@ag-ui/mastra`'s `syncInputStateToWorkingMemory` writes the UI's shared
state into Mastra working memory **before** it streams a turn. That
write is unguarded and never creates the thread, because it assumes the
resource-scoped store, which upserts. Its own comment says so, and its
*remote* branch handles the opposite case explicitly ("requires the
thread to exist… create the thread and retry once").

This starter was the one Mastra agent in the repo that set
`workingMemory.scope: "thread"`. Thread scope routes the same write to
thread metadata, and `@mastra/memory` throws `Thread <id> not found`
when the thread row does not exist. On the first turn of a conversation
it never does, so the run dies before the model is called.

Managed Intelligence made that certain rather than likely:
`handlers/intelligence/run.ts` replaces the client thread id with a
platform-canonical one from `ɵacquireThreadLock`, which the Mastra store
has never seen. That also explains the two different thread ids in the
same failure.

## Evidence

Verified by running, against the starter's exact pins (`@mastra/core`
1.41.0, `@mastra/memory` 1.0.1-alpha.1, `@ag-ui/mastra` 1.1.2):

| Configuration | First-turn state sync |
| --- | --- |
| `scope: "thread"` (as shipped) | throws `Thread <id> not found`, run
aborts |
| `scope: "resource"` | writes, reads back, reaches the agent's system
message |

Resource scope keeps working memory **per conversation** here, because
the bridge derives the resource id from the thread id when no explicit
resource id is configured. Confirmed: a second thread id reads back
`null`, and schema merge semantics still work on turn 2.

## Change

- `examples/integrations/mastra` uses `scope: "resource"`, matching
every other Mastra agent in this repo, with a comment explaining why.
- A new contract test in
`scripts/__tests__/integration-intelligence-migration.test.ts` fails if
any integration starter configures thread-scoped Mastra working memory.
It asserts the mastra starter is in scope, so it cannot pass vacuously,
and it ships with five helper cases including a decoy
(`observationalMemory.scope: "thread"`, which is unrelated and must not
trip it).

This also fixes the Channel host, which drives the same agent.

## Verification

- `vitest run
scripts/__tests__/integration-intelligence-migration.test.ts` — 159
passed, and the new test is red on the unfixed starter (`expected [
'mastra/src/mastra/agents/index.ts' ] to deeply equal []`).
- `parity:check` passes, `oxlint` and `oxfmt --check` clean.

## Left undone, deliberately

The adapter's local branch is still unguarded, so a developer who
chooses thread scope hits the same abort in their own code. The fix
belongs in `@ag-ui/mastra` and mirrors what its remote branch already
does. That needs an ag-ui PR plus a release, so it is not in this
change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Updated the weather agent’s working-memory scope to support shared UI
state during the first turn of a conversation.
* Prevented conversation initialization issues caused by thread-scoped
memory.

* **Tests**
* Added validation to ensure integrations use compatible working-memory
scopes.
* Added coverage for direct, nested, resource-scoped, omitted, and
unrelated configuration cases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ben Taylor
2026-09-04 15:46:49 -05:00
committed by GitHub
2 changed files with 164 additions and 1 deletions
@@ -4377,3 +4377,158 @@ function runAgentCoreDeployHarness(
fs.rmSync(harnessDirectory, { force: true, recursive: true });
}
}
/**
* Mastra working-memory scope contract.
*
* `@ag-ui/mastra` writes the client's shared state into Mastra working memory
* BEFORE it streams a turn (`syncInputStateToWorkingMemory`). That write is
* unguarded and never creates the thread, because it assumes the resource-scoped
* store, which upserts. Under `scope: "thread"` the same write goes to thread
* metadata instead, and `@mastra/memory` throws `Thread <id> not found` when the
* thread row does not exist yet. On the first turn of a thread it never does, so
* the run aborts before the model is called and the chat never answers.
*
* Managed Intelligence makes that certain rather than likely: the Intelligence
* run handler replaces the client thread id with a platform-canonical one, which
* the Mastra store has never seen.
*
* Resource scope is the correct setting for these starters and keeps working
* memory per conversation, because the bridge derives the resource id from the
* thread id when no explicit resource id is configured.
*/
const MASTRA_MEMORY_GLOB_DIRECTORIES = ["src/mastra"] as const;
/** Returns every `.ts` file under one directory, recursively. */
function typeScriptFilesUnder(directory: string): string[] {
if (!fs.existsSync(directory)) return [];
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return typeScriptFilesUnder(entryPath);
return entry.isFile() && entryPath.endsWith(".ts") ? [entryPath] : [];
});
}
/** Returns every Mastra source file across the integration starters. */
function mastraMemorySurfaces(): string[] {
return fs
.readdirSync(integrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((entry) =>
MASTRA_MEMORY_GLOB_DIRECTORIES.flatMap((relative) =>
typeScriptFilesUnder(path.join(integrationsDir, entry.name, relative)),
),
);
}
/**
* Returns whether one source configures thread-scoped Mastra working memory.
*
* @param contents - TypeScript source to inspect.
* @returns True when any `workingMemory` object sets `scope: "thread"`.
*/
function declaresThreadScopedWorkingMemory(contents: string): boolean {
const sourceFile = parseManagedSource(contents);
let found = false;
const visit = (node: ts.Node): void => {
if (found) return;
if (
ts.isPropertyAssignment(node) &&
propertyNameText(node.name) === "workingMemory"
) {
const initializer = unwrapExpression(node.initializer);
if (
ts.isObjectLiteralExpression(initializer) &&
objectPropertyIsString(initializer, "scope", "thread")
) {
found = true;
return;
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
return found;
}
test("integration starters never configure thread-scoped Mastra working memory", () => {
const surfaces = mastraMemorySurfaces();
// Guards against a vacuous pass: the mastra starter must be in scope.
expect(
surfaces.some((surface) =>
surface.includes(path.join("mastra", "src", "mastra")),
),
"the mastra starter's Mastra sources must be scanned",
).toBe(true);
const offenders = surfaces.filter((surface) =>
declaresThreadScopedWorkingMemory(fs.readFileSync(surface, "utf8")),
);
expect(
offenders.map((surface) => path.relative(integrationsDir, surface)),
"thread-scoped working memory aborts the first turn of every new thread",
).toEqual([]);
});
test.each([
{
configuration: "thread scope",
contents: `
new Memory({
options: { workingMemory: { enabled: true, scope: "thread" } },
});
`,
expected: true,
},
{
configuration: "resource scope",
contents: `
new Memory({
options: { workingMemory: { enabled: true, scope: "resource" } },
});
`,
expected: false,
},
{
configuration: "an omitted scope",
contents: `
new Memory({ options: { workingMemory: { enabled: true } } });
`,
expected: false,
},
{
configuration: "thread scope nested below other options",
contents: `
new Agent({
memory: new Memory({
options: {
workingMemory: { enabled: true, schema: AgentState, scope: "thread" },
},
}),
});
`,
expected: true,
},
{
configuration: "an unrelated thread-scoped option",
contents: `
new Memory({
options: { semanticRecall: { scope: "thread" } },
});
`,
expected: false,
},
])(
"the Mastra working-memory scope helper detects $configuration",
({ contents, expected }) => {
expect(declaresThreadScopedWorkingMemory(contents)).toBe(expected);
},
);