From 88ff9e08a9078dc346d6131f4e3211ada3cfec58 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 12 Oct 2026 18:01:25 +0200 Subject: [PATCH] u --- API.md | 8 +-- API.modelconversation.md | 13 ++--- scripts/test-modelconversation.js | 29 +++++++++++ .../mssql/queries/modelconversation.js | 52 +++++++++++++++---- src/providers/mssql/schema.js | 2 + src/providers/sqlite3/schema.js | 2 + src/store/modelconversation/create.js | 10 +++- src/store/modelconversation/delete.js | 3 +- src/store/modelconversation/get.js | 2 +- src/store/modelconversation/list.js | 7 ++- src/store/modelconversation/setState.js | 2 +- src/store/modelconversation/shape.js | 1 + 12 files changed, 104 insertions(+), 27 deletions(-) diff --git a/API.md b/API.md index 8b8ec2a..14cf3ae 100644 --- a/API.md +++ b/API.md @@ -298,11 +298,11 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise>` - `setByCategoryId(googleAuthId: number, categoryId: number, properties: Array<{propertyId: number, value: string, sort?: number}>, tx?: object): Promise>` - [modelconversation](API.modelconversation.md) - - `create(googleAuthId: number, input: {provider: string, model: string}, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>` + - `create(googleAuthId: number, input: {provider: string, model: string, started_by?: number|null}, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>` - `delete(googleAuthId: number, conversationId: number, tx?: object): Promise` - - `get(googleAuthId: number, conversationId: number, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>` - - `list(googleAuthId: number, tx?: object): Promise>` - - `setState(googleAuthId: number, conversationId: number, state: string, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>` + - `get(googleAuthId: number, conversationId: number, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>` + - `list(googleAuthId: number, tx?: object): Promise>` + - `setState(googleAuthId: number, conversationId: number, state: string, tx?: object): Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>` - [modelmessage](API.modelmessage.md) - `add(googleAuthId: number, conversationId: number, input: {message: string, role?: 'user'|'assistant'|'system'|'tool'|'reasoning'|'usage'}, tx?: object): Promise<{id: number, conversation_id: number, created_at: string, role: string, message: string}>` - `list(googleAuthId: number, conversationId: number, tx?: object): Promise>` diff --git a/API.modelconversation.md b/API.modelconversation.md index 4000396..c5d8e38 100644 --- a/API.modelconversation.md +++ b/API.modelconversation.md @@ -16,15 +16,16 @@ Creates a new (idle) model conversation for the user in the selected company. | `input` | `object` | Conversation input. | | `input.provider` | `string` | LLM provider name. | | `input.model` | `string` | Model identifier. | +| `[input.started_by]` | `number|null` | Id of the conversation that starts this one as a subagent (unenforced reference; omit/null for a top-level conversation). | | `[tx]` | `object` | Optional transaction to run within. | **Returns** -`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>` +`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>` **Throws** -`Error` — 'Provider required' or 'Model required'. +`Error` — 'Provider required', 'Model required' or 'Invalid started_by'. **Example** @@ -36,7 +37,7 @@ const c = await store.modelconversation.create(42, { provider: 'openrouter', mod *Source: `src/store/modelconversation/delete.js`* -Deletes a model conversation by ID for the user in the selected company. +Deletes a model conversation by ID for the user in the selected company, together with every subagent conversation below it (`started_by` chain, recursively) and their messages. **Parameters** @@ -70,7 +71,7 @@ Gets a single model conversation by ID for the user in the selected company. **Returns** -`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>` +`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>` **Throws** @@ -91,7 +92,7 @@ Lists the user's own model conversations in the selected company, newest activit **Returns** -`Promise>` +`Promise>` — `started_by` is the originating conversation id for subagent conversations (null otherwise), so clients can build a tree. `first_user_message` is the stored `message` payload of the conversation's first `user` message (null if none yet). **Example** @@ -116,7 +117,7 @@ Sets the state of a model conversation ('idle' | 'busy'). **Returns** -`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>` +`Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>` **Throws** diff --git a/scripts/test-modelconversation.js b/scripts/test-modelconversation.js index a7751a0..e512104 100644 --- a/scripts/test-modelconversation.js +++ b/scripts/test-modelconversation.js @@ -33,6 +33,7 @@ async function run() { const list = await store.modelconversation.list(userA); assert.equal(list.length, 1); + assert.equal(list[0].first_user_message, null); assert.deepEqual(await store.modelmessage.list(userA, conv.id), []); @@ -65,6 +66,7 @@ async function run() { const msgs = await store.modelmessage.list(userA, conv.id); assert.deepEqual(msgs.map((m) => m.message), ['hello', 'echo: hello', 'second']); assert.deepEqual(msgs.map((m) => m.role), ['user', 'assistant', 'user']); + assert.equal((await store.modelconversation.list(userA)).find((c) => c.id === conv.id).first_user_message, 'hello'); // Other user cannot see or write const userB = await store.google_auth.add('mc-b@example.com'); @@ -74,6 +76,33 @@ async function run() { await assert.rejects(store.modelmessage.list(userB, conv.id), /Conversation not found/); await assert.rejects(store.modelmessage.add(userB, conv.id, { message: 'x' }), /Conversation not found/); + // started_by: top-level is null, subagents link to their originator, list exposes it + assert.equal(conv.started_by, null); + const sub1 = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: conv.id }); + assert.equal(sub1.started_by, conv.id); + const sub2 = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: sub1.id }); + const sibling = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm' }); + await assert.rejects( + store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: 0 }), + /Invalid started_by/, + ); + const listed = await store.modelconversation.list(userA); + assert.equal(listed.find((c) => c.id === sub2.id).started_by, sub1.id); + assert.equal((await store.modelconversation.get(userA, sub1.id)).started_by, conv.id); + await store.modelmessage.add(userA, sub2.id, { message: 'deep' }); + + // Deleting a child removes only its own chain; deleting the root removes the whole chain + await store.modelconversation.delete(userA, sub2.id); + await assert.rejects(store.modelconversation.get(userA, sub2.id), /Conversation not found/); + await store.modelconversation.get(userA, sub1.id); + const sub3 = await store.modelconversation.create(userA, { provider: 'openrouter', model: 'm', started_by: sub1.id }); + await store.modelconversation.delete(userA, conv.id); + for (const gone of [conv.id, sub1.id, sub3.id]) { + await assert.rejects(store.modelconversation.get(userA, gone), /Conversation not found/); + } + await store.modelconversation.get(userA, sibling.id); + await assert.rejects(store.modelconversation.delete(userA, conv.id), /Conversation not found/); + console.log('PASS'); } finally { await provider.close?.(); diff --git a/src/providers/mssql/queries/modelconversation.js b/src/providers/mssql/queries/modelconversation.js index 3930cde..5ee2086 100644 --- a/src/providers/mssql/queries/modelconversation.js +++ b/src/providers/mssql/queries/modelconversation.js @@ -5,15 +5,25 @@ export const MODELCONVERSATION_COLUMNS = ` model, created_at, updated_at, - state + state, + started_by `; export const LIST_BY_COMPANYUSER = ` SELECT -${MODELCONVERSATION_COLUMNS} -FROM modelconversation -WHERE companyuser_id = @companyuserId -ORDER BY updated_at DESC, id DESC +${MODELCONVERSATION_COLUMNS}, + ( + SELECT m.message + FROM modelmessage m + WHERE m.id = ( + SELECT MIN(f.id) + FROM modelmessage f + WHERE f.conversation_id = c.id AND f.role = 'user' + ) + ) AS first_user_message +FROM modelconversation c +WHERE c.companyuser_id = @companyuserId +ORDER BY c.updated_at DESC, c.id DESC `; export const GET_BY_COMPANYUSER_AND_ID = ` @@ -27,7 +37,8 @@ export const CREATE = ` INSERT INTO modelconversation ( companyuser_id, provider, - model + model, + started_by ) OUTPUT INSERTED.id, @@ -36,11 +47,13 @@ OUTPUT INSERTED.model, INSERTED.created_at, INSERTED.updated_at, - INSERTED.state + INSERTED.state, + INSERTED.started_by VALUES ( @companyuserId, @provider, - @model + @model, + @startedBy ) `; @@ -69,11 +82,30 @@ OUTPUT INSERTED.model, INSERTED.created_at, INSERTED.updated_at, - INSERTED.state + INSERTED.state, + INSERTED.started_by WHERE id = @id AND companyuser_id = @companyuserId `; +/** + * Deletes a conversation and, because started_by is not a foreign key, the whole chain of + * subagent conversations below it (started_by -> id, recursively) in a single statement. + * Messages go with their conversations via ON DELETE CASCADE. rowsAffected is 0 only when + * the root conversation does not exist for this user (a root always yields >= 1 row). + */ export const DELETE_BY_COMPANYUSER_AND_ID = ` +WITH subtree AS ( + SELECT id + FROM modelconversation + WHERE id = @id AND companyuser_id = @companyuserId + + UNION ALL + + SELECT c.id + FROM modelconversation c + INNER JOIN subtree s ON c.started_by = s.id + WHERE c.companyuser_id = @companyuserId +) DELETE FROM modelconversation -WHERE id = @id AND companyuser_id = @companyuserId +WHERE id IN (SELECT id FROM subtree) `; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index d269d18..7d766e3 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -819,10 +819,12 @@ BEGIN created_at DATETIME2 NOT NULL CONSTRAINT DF_modelconversation_created_at DEFAULT SYSUTCDATETIME(), updated_at DATETIME2 NOT NULL CONSTRAINT DF_modelconversation_updated_at DEFAULT SYSUTCDATETIME(), state NVARCHAR(20) NOT NULL CONSTRAINT DF_modelconversation_state DEFAULT 'idle', + started_by INT NULL, -- id of the conversation that started this one as a subagent; deliberately NOT a foreign key CONSTRAINT FK_modelconversation_companyuser FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE CASCADE, CONSTRAINT CK_modelconversation_state CHECK (state IN ('idle', 'busy')) ); CREATE INDEX IX_modelconversation_companyuser ON modelconversation (companyuser_id, updated_at); + CREATE INDEX IX_modelconversation_started_by ON modelconversation (started_by); END `; diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 67ef3c2..4eadd15 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -511,9 +511,11 @@ CREATE TABLE IF NOT EXISTS modelconversation ( created_at TEXT NOT NULL DEFAULT (${UTC_NOW}), updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}), state TEXT NOT NULL DEFAULT 'idle' CHECK (state IN ('idle', 'busy')), + started_by INTEGER, -- id of the conversation that started this one as a subagent; deliberately NOT a foreign key FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS IX_modelconversation_companyuser ON modelconversation (companyuser_id, updated_at); +CREATE INDEX IF NOT EXISTS IX_modelconversation_started_by ON modelconversation (started_by); `; diff --git a/src/store/modelconversation/create.js b/src/store/modelconversation/create.js index f146b08..df406b2 100644 --- a/src/store/modelconversation/create.js +++ b/src/store/modelconversation/create.js @@ -15,9 +15,10 @@ export default function create(provider) { * @param {object} input - Conversation input. * @param {string} input.provider - LLM provider name. * @param {string} input.model - Model identifier. + * @param {number|null} [input.started_by] - Id of the conversation that starts this one as a subagent (unenforced reference; omit/null for a top-level conversation). * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>} - * @throws {Error} 'Provider required' or 'Model required'. + * @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>} + * @throws {Error} 'Provider required', 'Model required' or 'Invalid started_by'. * * @example * const c = await store.modelconversation.create(42, { provider: 'openrouter', model: 'openai/gpt-5' }); @@ -28,6 +29,10 @@ export default function create(provider) { if (!providerName) throw new Error('Provider required'); const model = typeof input.model === 'string' ? input.model.trim() : ''; if (!model) throw new Error('Model required'); + const startedBy = input.started_by ?? null; + if (startedBy !== null && (!Number.isInteger(startedBy) || startedBy <= 0)) { + throw new Error('Invalid started_by'); + } const runner = makeRunner(provider, tx); const { companyuserId } = await access(googleAuthId, runner); @@ -36,6 +41,7 @@ export default function create(provider) { companyuserId, provider: providerName, model, + startedBy, }); return shape(result.rows[0]); }; diff --git a/src/store/modelconversation/delete.js b/src/store/modelconversation/delete.js index c019d90..49888bb 100644 --- a/src/store/modelconversation/delete.js +++ b/src/store/modelconversation/delete.js @@ -9,7 +9,8 @@ export default function del(provider) { provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** - * Deletes a model conversation by ID for the user in the selected company. + * Deletes a model conversation by ID for the user in the selected company, together with + * every subagent conversation below it (`started_by` chain, recursively) and their messages. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} conversationId - The conversation id. diff --git a/src/store/modelconversation/get.js b/src/store/modelconversation/get.js index d2b34b8..a259b95 100644 --- a/src/store/modelconversation/get.js +++ b/src/store/modelconversation/get.js @@ -14,7 +14,7 @@ export default function get(provider) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} conversationId - The conversation id. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>} + * @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>} * @throws {Error} 'Conversation not found'. */ return async (googleAuthId, conversationId, tx) => { diff --git a/src/store/modelconversation/list.js b/src/store/modelconversation/list.js index 7d83123..3b0de76 100644 --- a/src/store/modelconversation/list.js +++ b/src/store/modelconversation/list.js @@ -13,7 +13,7 @@ export default function list(provider) { * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise>} + * @returns {Promise>} `started_by` is the originating conversation id for subagent conversations (null otherwise), so clients can build a tree. `first_user_message` is the stored `message` payload of the conversation's first `user` message (null if none yet). * * @example * const conversations = await store.modelconversation.list(42); @@ -22,6 +22,9 @@ export default function list(provider) { const runner = makeRunner(provider, tx); const { companyuserId } = await access(googleAuthId, runner); const result = await runner.query(sql, { companyuserId }); - return result.rows.map(shape); + return result.rows.map((row) => ({ + ...shape(row), + first_user_message: row.first_user_message ?? null, + })); }; } diff --git a/src/store/modelconversation/setState.js b/src/store/modelconversation/setState.js index 26e7d33..45da08f 100644 --- a/src/store/modelconversation/setState.js +++ b/src/store/modelconversation/setState.js @@ -15,7 +15,7 @@ export default function setState(provider) { * @param {number} conversationId - The conversation id. * @param {string} state - The new state ('idle' | 'busy'). * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string}>} + * @returns {Promise<{id: number, companyuser_id: number, provider: string, model: string, created_at: string, updated_at: string, state: string, started_by: number|null}>} * @throws {Error} 'Invalid state' or 'Conversation not found'. */ return async (googleAuthId, conversationId, state, tx) => { diff --git a/src/store/modelconversation/shape.js b/src/store/modelconversation/shape.js index 94b1888..16c1edc 100644 --- a/src/store/modelconversation/shape.js +++ b/src/store/modelconversation/shape.js @@ -7,5 +7,6 @@ export default function shape(row) { created_at: row.created_at ?? row.createdAt, updated_at: row.updated_at ?? row.updatedAt, state: row.state, + started_by: row.started_by ?? row.startedBy ?? null, }; }