feat: app conversations through the memory_id query parameter (#12025)

* feat: app conversations through the memory_id query parameter, chat-mode flows included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: regenerate the mcp session instructions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: type backendAsync's memoryId option in the eval diagnostics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin git-sync hub scripts to windmill-cli 1.849.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: memoryId option on the sync backend call too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: bump the ui builder artifact for the memoryId option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: shared-utils 1.0.14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: cli takes shared-utils 1.0.14 for the memoryId option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the app conversation scoping and the memoryId signatures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: assert the ownership refusal, not any refusal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: match the memoryId option in the cli wmill.ts signature tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: file an app's turns as the deployed flow's conversation, preview included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-27 23:51:35 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 003a6a9fd6
commit 07d4f7fe61
29 changed files with 263 additions and 131 deletions
+2 -2
View File
@@ -9,7 +9,7 @@
"@cliffy/command": "npm:@jsr/cliffy__command@1.0.0",
"@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0",
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
"@windmill-labs/shared-utils": "^1.0.13",
"@windmill-labs/shared-utils": "^1.0.14",
"diff": "^5.2.0",
"esbuild": "0.28.2",
"get-port": "7.1.0",
@@ -175,7 +175,7 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@windmill-labs/shared-utils": ["@windmill-labs/shared-utils@1.0.13", "", {}, "sha512-LB3bFq8i0bS2fwM8EqGRODrR1CXLXXyiNa766By1ZbSJYCmTzK8NVXqcluJP1ne7is80LRVZdOgWTw6YX+61Dg=="],
"@windmill-labs/shared-utils": ["@windmill-labs/shared-utils@1.0.14", "", {}, "sha512-sD8G8EFQW+ml66kbsdvazDNOb96j9te88HHpwJDrJmNU5KXiuQuGkDo8beZgQ6SvDubY72pZjz5eJBIrbIXPWQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+1 -1
View File
@@ -21,7 +21,7 @@
"@cliffy/command": "npm:@jsr/cliffy__command@1.0.0",
"@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0",
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
"@windmill-labs/shared-utils": "^1.0.13",
"@windmill-labs/shared-utils": "^1.0.14",
"diff": "^5.2.0",
"esbuild": "0.28.2",
"get-port": "7.1.0",
+10 -11
View File
@@ -1155,7 +1155,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
colors.cyan(`[WebSocket] Received: ${JSON.stringify(message)}`),
);
const { type, reqId, runnable_id, v, jobId } = message;
const { type, reqId, runnable_id, v, jobId, memoryId } = message;
// Helper to send response
const respond = (responseType: string, result: any, error: boolean) => {
@@ -1166,6 +1166,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
const runAndWaitForResult = async (
runnableId: string,
args: any,
memoryId?: string,
): Promise<{ uuid: string; result: any }> => {
const runnables = await loadRunnables();
const runnable = runnables[runnableId];
@@ -1182,6 +1183,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
args,
appTempRefs,
previewModules(runnables, runnableId),
memoryId,
);
log.info(colors.gray(`[backend] Job started: ${uuid}`));
@@ -1194,7 +1196,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
// Run a runnable synchronously and wait for result
log.info(colors.blue(`[backend] Running runnable: ${runnable_id}`));
try {
const { result } = await runAndWaitForResult(runnable_id, v);
const { result } = await runAndWaitForResult(runnable_id, v, memoryId);
respond("backendRes", result, false);
} catch (error: any) {
log.error(colors.red(`[backend] Error: ${error.message}`));
@@ -1230,6 +1232,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
v,
appTempRefs,
previewModules(runnables, runnable_id),
memoryId,
);
log.info(colors.gray(`[backendAsync] Job started: ${uuid}`));
@@ -1713,8 +1716,8 @@ export function buildWmillTs(
/**
* The inputs of an agent runnable, after the agent's memory (see the frontend's
* `agentRunnableSchema`): `memory_id` when it keeps managed memory, `previous_messages` when it
* is off, and neither for an older `manual` setting.
* `agentRunnableSchema`): `previous_messages` when it keeps no memory. The conversation of one
* that does is `backendAsync`'s `memoryId` option, not an input.
*/
function agentRunnableSchema(memory: any) {
const managed =
@@ -1723,13 +1726,7 @@ function agentRunnableSchema(memory: any) {
Boolean(memory.context_length));
const legacy = memory?.kind === "manual";
const history: Record<string, any> = {};
if (managed) {
history.memory_id = {
type: "string",
description:
"The conversation the message belongs to: reuse it to continue one, or omit it for a single run",
};
} else if (!legacy) {
if (!managed && !legacy) {
history.previous_messages = {
type: "array",
description:
@@ -1922,10 +1919,12 @@ async function executeRunnable(
args: any,
tempScriptRefs?: Record<string, string>,
modules?: Record<string, { content: string; language: string }>,
memoryId?: string,
): Promise<string> {
const requestBody: any = {
component: runnableId,
args: args ?? {},
...(memoryId ? { run_query_params: { memory_id: memoryId } } : {}),
force_viewer_static_fields: {},
force_viewer_one_of_fields: {},
force_viewer_allow_user_resources: [],
+4 -4
View File
@@ -115,8 +115,8 @@ export const backend = new Proxy(
{},
{
get(_, runnable_id: string) {
return (v: any) => {
return doRequest('backend', { runnable_id, v })
return (v: any, opts?: { memoryId?: string }) => {
return doRequest('backend', { runnable_id, v, memoryId: opts?.memoryId })
}
}
})
@@ -125,8 +125,8 @@ export const backendAsync = new Proxy(
{},
{
get(_, runnable_id: string) {
return (v: any) => {
return doRequest('backendAsync', { runnable_id, v })
return (v: any, opts?: { memoryId?: string }) => {
return doRequest('backendAsync', { runnable_id, v, memoryId: opts?.memoryId })
}
}
})
+2 -2
View File
@@ -6882,7 +6882,7 @@ import * as wmill from './wmill'
const chat = useWindmillChat({ wmill, runnable: 'chat' })
\`\`\`
\`runnable\` is the runnable's key; each runnable keeps its own conversations. The runnable takes \`user_message\` and \`memory_id\`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as \`backend.<key>({ user_message })\` without \`memory_id\`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes \`previous_messages\` (chat messages) instead of \`memory_id\`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. \`chat.stop()\` stops following without cancelling the turn, and a message sent while it still runs is refused with \`TurnRunningError\`. Attachments are not supported this way.
\`runnable\` is the runnable's key; each runnable keeps its own conversations. The runnable takes \`user_message\`; the chat passes the conversation id as \`backendAsync.<key>(args, { memoryId })\`: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as \`backend.<key>({ user_message })\`, it is a single run with no conversation, for a one-off question; \`backend.<key>(args, { memoryId })\` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes \`previous_messages\` (chat messages) instead, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. \`chat.stop()\` stops following without cancelling the turn, and a message sent while it still runs is refused with \`TurnRunningError\`. Attachments are not supported this way.
## Backend runnables
@@ -6938,7 +6938,7 @@ Use only \`wmill\` functions the SDK actually exports; for an endpoint none of t
### Path runnables (script / flow / hubscript / agent)
When \`type\` is \`script\`, \`flow\`, \`hubscript\` or \`agent\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is \`user_message\` and \`user_attachments\`, plus \`memory_id\` when the agent keeps managed memory or \`previous_messages\` when its memory is off.
When \`type\` is \`script\`, \`flow\`, \`hubscript\` or \`agent\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is \`user_message\` and \`user_attachments\`, plus \`previous_messages\` when its memory is off.
Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at \`hub/<version>/<app>/<name>\`) for a third-party service, and reference it instead of copying its logic.
+1 -1
View File
@@ -105,7 +105,7 @@ test("fetchPathRunnableSchemas pulls schema for a path-based script and feeds wm
expect(ts).toContain("userId");
expect(ts).toContain("includeProfile");
expect(ts).toMatch(/fetchUser:\s*\(args:\s*\{[^}]*userId/);
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}\)/);
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}[,)]/);
// Sanity: the generated d.ts is syntactically what the dev server writes
const outPath = path.join(tempDir, "wmill.d.ts.out");
+4 -4
View File
@@ -126,7 +126,7 @@ describe("genWmillTs (regression: path-based runnable schema)", () => {
expect(ts).toMatch(/fetchUser:\s*\(args:\s*\{[^}]*userId/);
expect(ts).toContain("userId");
expect(ts).toContain("includeProfile");
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}\)/);
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}[,)]/);
});
test("path-based runnable without schema falls back to {}", async () => {
@@ -139,7 +139,7 @@ describe("genWmillTs (regression: path-based runnable schema)", () => {
const runnables = await loadRunnablesFromBackend(backendDir);
const ts = buildWmillTs(runnables);
expect(ts).toMatch(/noSchema:\s*\(args:\s*\{\s*\}\)/);
expect(ts).toMatch(/noSchema:\s*\(args:\s*\{\s*\}[,)]/);
});
});
@@ -192,7 +192,7 @@ describe("inferRunnableSchemaFromFile (regression: new backend-folder format)",
// and the runnable would render as `args: {}`.
expect(ts).toContain("compute:");
expect(ts).toMatch(/compute:\s*\(args:\s*\{[^}]*x:/);
expect(ts).not.toMatch(/compute:\s*\(args:\s*\{\s*\}\)/);
expect(ts).not.toMatch(/compute:\s*\(args:\s*\{\s*\}[,)]/);
});
test("returns undefined for path-based runnables", async () => {
@@ -297,6 +297,6 @@ describe("inferAllInlineSchemas (startup seed for wmill.d.ts)", () => {
expect(ts).toMatch(/greet:\s*\(args:\s*\{[^}]*name/);
expect(ts).toContain("name");
expect(ts).toContain("times");
expect(ts).not.toMatch(/greet:\s*\(args:\s*\{\s*\}\)/);
expect(ts).not.toMatch(/greet:\s*\(args:\s*\{\s*\}[,)]/);
});
});