@axiom-lattice/gateway 2.1.91 → 2.1.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/gateway",
3
- "version": "2.1.91",
3
+ "version": "2.1.93",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -40,11 +40,11 @@
40
40
  "redis": "^5.0.1",
41
41
  "uuid": "^9.0.1",
42
42
  "zod": "3.25.76",
43
- "@axiom-lattice/agent-eval": "2.1.73",
44
- "@axiom-lattice/core": "2.1.79",
45
- "@axiom-lattice/pg-stores": "1.0.70",
46
- "@axiom-lattice/protocols": "2.1.41",
47
- "@axiom-lattice/queue-redis": "1.0.40"
43
+ "@axiom-lattice/agent-eval": "2.1.75",
44
+ "@axiom-lattice/core": "2.1.81",
45
+ "@axiom-lattice/pg-stores": "1.0.72",
46
+ "@axiom-lattice/protocols": "2.1.43",
47
+ "@axiom-lattice/queue-redis": "1.0.42"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/jest": "^29.5.14",
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Seed script: creates a Lark channel installation in the local SQLite store.
3
+ *
4
+ * Usage:
5
+ * pnpm --filter @axiom-lattice/gateway exec tsx scripts/seed-lark-installation.ts
6
+ */
7
+
8
+ import {
9
+ initDatabase,
10
+ getDatabase,
11
+ LocalChannelInstallationStore,
12
+ closeDatabase,
13
+ } from "@axiom-lattice/local-stores";
14
+
15
+ const APP_ID = "cli_a92dc36def309cca";
16
+ const APP_SECRET = "hNTWhEfti4Y7JIq8JA46Af7WNPcAclGa";
17
+ const INSTALLATION_ID = "lark-main";
18
+ const TENANT_ID = "default";
19
+
20
+ async function main(): Promise<void> {
21
+ const dbPath = process.env.LOCAL_DB_PATH || "~/.axiom/lattice.db";
22
+
23
+ console.log(`[seed] Initializing SQLite at ${dbPath}`);
24
+ await initDatabase({ dbPath });
25
+
26
+ const db = getDatabase();
27
+ const store = new LocalChannelInstallationStore(db);
28
+
29
+ console.log(`[seed] Creating Lark installation: ${INSTALLATION_ID}`);
30
+ const installation = await store.createInstallation(
31
+ TENANT_ID,
32
+ INSTALLATION_ID,
33
+ {
34
+ channel: "lark",
35
+ config: {
36
+ appId: APP_ID,
37
+ appSecret: APP_SECRET,
38
+ },
39
+ enabled: true,
40
+ rejectWhenNoBinding: false,
41
+ },
42
+ );
43
+
44
+ console.log(`[seed] Installation created:`);
45
+ console.log(JSON.stringify(installation, null, 2));
46
+
47
+ await closeDatabase();
48
+ console.log(`[seed] Done. Installation persisted to ${dbPath}`);
49
+ }
50
+
51
+ main().catch((err) => {
52
+ console.error("[seed] Failed:", err);
53
+ process.exit(1);
54
+ });
@@ -8,6 +8,71 @@ import type {
8
8
  } from "@axiom-lattice/protocols";
9
9
  import type { LarkChannelInstallationConfig } from "@axiom-lattice/protocols";
10
10
  import { parseLarkMessageEvent } from "./parser";
11
+ import { Logger } from "../../logger/Logger";
12
+ import * as Lark from "@larksuiteoapi/node-sdk";
13
+
14
+ const logger = new Logger({ serviceName: "lattice/gateway/lark" });
15
+
16
+ // ─── WS connection state ──────────────────────────────────────────────────
17
+
18
+ const activeConnections = new Map<string, Lark.WSClient>();
19
+
20
+ // ─── WS message parsing (shared with lark-ws) ─────────────────────────────
21
+
22
+ interface LarkWSMessageEvent {
23
+ sender?: { sender_id?: { open_id?: string } };
24
+ message?: {
25
+ message_id?: string;
26
+ chat_id?: string;
27
+ chat_type?: string;
28
+ message_type?: string;
29
+ content?: string;
30
+ };
31
+ }
32
+
33
+ function parseTextContent(content: string | undefined): string | null {
34
+ if (!content) return null;
35
+ try {
36
+ const parsed = JSON.parse(content) as { text?: string };
37
+ return typeof parsed.text === "string" ? parsed.text : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ function normalizeChatType(chatType: string | undefined): "direct" | "group" {
42
+ return chatType === "p2p" ? "direct" : "group";
43
+ }
44
+
45
+ function wsEventToInbound(
46
+ event: LarkWSMessageEvent,
47
+ installationId: string,
48
+ tenantId: string,
49
+ ): InboundMessage | null {
50
+ const messageId = event.message?.message_id;
51
+ const chatId = event.message?.chat_id;
52
+ const openId = event.sender?.sender_id?.open_id;
53
+ if (!messageId || !chatId || !openId) return null;
54
+ if (event.message?.message_type !== "text") return null;
55
+
56
+ const text = parseTextContent(event.message.content);
57
+ if (!text) return null;
58
+
59
+ const chatType = normalizeChatType(event.message.chat_type);
60
+ return {
61
+ channel: "lark",
62
+ channelInstallationId: installationId,
63
+ tenantId,
64
+ sender: { id: openId, displayName: undefined },
65
+ content: { text, metadata: { chatId, chatType, messageId } },
66
+ conversation: { id: chatId, type: chatType },
67
+ replyTarget: {
68
+ adapterChannel: "lark",
69
+ channelInstallationId: installationId,
70
+ rawTarget: { chatId, messageId, chatType },
71
+ },
72
+ };
73
+ }
74
+
75
+ // ─── Adapter ──────────────────────────────────────────────────────────────
11
76
 
12
77
  export const larkConfigSchema = z.object({
13
78
  appId: z.string(),
@@ -32,30 +97,16 @@ export const larkChannelAdapter: ChannelAdapter<LarkChannelInstallationConfig> =
32
97
  channel: "lark",
33
98
  channelInstallationId: installation.id,
34
99
  tenantId: installation.tenantId,
35
- sender: {
36
- id: event.openId,
37
- displayName: undefined,
38
- },
100
+ sender: { id: event.openId, displayName: undefined },
39
101
  content: {
40
102
  text: event.text,
41
- metadata: {
42
- chatId: event.chatId,
43
- chatType: event.chatType,
44
- messageId: event.messageId,
45
- },
46
- },
47
- conversation: {
48
- id: event.chatId,
49
- type: event.chatType,
103
+ metadata: { chatId: event.chatId, chatType: event.chatType, messageId: event.messageId },
50
104
  },
105
+ conversation: { id: event.chatId, type: event.chatType },
51
106
  replyTarget: {
52
107
  adapterChannel: "lark",
53
108
  channelInstallationId: installation.id,
54
- rawTarget: {
55
- chatId: event.chatId,
56
- messageId: event.messageId,
57
- chatType: event.chatType,
58
- },
109
+ rawTarget: { chatId: event.chatId, messageId: event.messageId, chatType: event.chatType },
59
110
  },
60
111
  };
61
112
  },
@@ -72,4 +123,75 @@ export const larkChannelAdapter: ChannelAdapter<LarkChannelInstallationConfig> =
72
123
  text: message.text,
73
124
  });
74
125
  },
126
+
127
+ resolveThreadId(message: InboundMessage, binding: unknown): string {
128
+ const date = new Date().toISOString().split("T")[0];
129
+ const chatType = message.conversation?.type === "direct" ? "dm" : "group";
130
+ const agentId = (binding as { agentId: string }).agentId;
131
+ if (chatType === "dm") {
132
+ return `lark:dm:${message.sender.id}:${agentId}:${date}`;
133
+ }
134
+ return `lark:group:${message.conversation?.id ?? "unknown"}:${agentId}:${date}`;
135
+ },
136
+
137
+ async connect(
138
+ installation: ChannelInstallation<LarkChannelInstallationConfig>,
139
+ deps?: unknown,
140
+ ): Promise<void> {
141
+ const { id: installationId, tenantId, config } = installation;
142
+
143
+ if (!config.appId || !config.appSecret) {
144
+ logger.warn("Lark installation missing credentials, skipping", { installationId });
145
+ return;
146
+ }
147
+
148
+ if (activeConnections.has(installationId)) {
149
+ logger.warn("Lark WS already connected for installation, skipping", { installationId });
150
+ return;
151
+ }
152
+
153
+ logger.info("Lark WS client starting", { installationId, tenantId });
154
+
155
+ const router = (deps as { router?: { dispatch(msg: InboundMessage): Promise<unknown> } })?.router;
156
+
157
+ const eventDispatcher = new Lark.EventDispatcher({}).register({
158
+ "im.message.receive_v1": async (data: LarkWSMessageEvent) => {
159
+ try {
160
+ const inbound = wsEventToInbound(data, installationId, tenantId);
161
+ if (!inbound) return;
162
+
163
+ logger.info("Lark WS message received", {
164
+ installationId,
165
+ senderId: inbound.sender.id,
166
+ chatId: data.message?.chat_id,
167
+ });
168
+
169
+ if (router) {
170
+ const result = await router.dispatch(inbound);
171
+ if (!(result as { success?: boolean }).success) {
172
+ logger.warn("Lark WS dispatch failed", {
173
+ installationId,
174
+ error: (result as { error?: { message?: string } }).error?.message,
175
+ });
176
+ }
177
+ }
178
+ } catch (err) {
179
+ logger.error("Lark WS event handler error", {
180
+ installationId,
181
+ error: err instanceof Error ? err.message : String(err),
182
+ });
183
+ }
184
+ },
185
+ });
186
+
187
+ const client = new Lark.WSClient({
188
+ appId: config.appId,
189
+ appSecret: config.appSecret,
190
+ loggerLevel: Lark.LoggerLevel.info,
191
+ });
192
+
193
+ await client.start({ eventDispatcher });
194
+ activeConnections.set(installationId, client);
195
+ logger.info("Lark WS client connected", { installationId });
196
+ },
75
197
  };
@@ -18,16 +18,3 @@ export interface LarkUrlVerificationPayload {
18
18
  encrypt?: string;
19
19
  }
20
20
 
21
- export interface LarkIngressConfig {
22
- enabled: boolean;
23
- installationId?: string;
24
- appId: string;
25
- appSecret: string;
26
- verificationToken?: string;
27
- encryptKey?: string;
28
- tenantId: string;
29
- assistantId: string;
30
- workspaceId?: string;
31
- projectId?: string;
32
- mappingMode: "user" | "group" | "hybrid";
33
- }
@@ -1,6 +1,5 @@
1
1
  import crypto from "crypto";
2
- import type { FastifyRequest } from "fastify";
3
- import type { LarkIngressConfig, LarkUrlVerificationPayload } from "./types";
2
+ import type { LarkUrlVerificationPayload } from "./types";
4
3
 
5
4
  export function parseLarkRequestBody(
6
5
  body: unknown,
@@ -31,37 +30,3 @@ export function decryptLarkPayload(
31
30
 
32
31
  return JSON.parse(plaintext) as LarkUrlVerificationPayload;
33
32
  }
34
-
35
- export function createLarkRequestVerifier(config: LarkIngressConfig) {
36
- return function verifyRequest(request: FastifyRequest): boolean {
37
- const body = parseLarkRequestBody(request.body, config.encryptKey);
38
-
39
- return verifyLarkParsedBody(body, config);
40
- };
41
- }
42
-
43
- export function verifyLarkParsedBody(
44
- body: LarkUrlVerificationPayload,
45
- config: LarkIngressConfig,
46
- ): boolean {
47
-
48
- if (!config.verificationToken) {
49
- return true;
50
- }
51
-
52
- return extractVerificationToken(body) === config.verificationToken;
53
- }
54
-
55
- function extractVerificationToken(
56
- body: LarkUrlVerificationPayload,
57
- ): string | undefined {
58
- if (typeof body.token === "string") {
59
- return body.token;
60
- }
61
-
62
- if (typeof body.header?.token === "string") {
63
- return body.header.token;
64
- }
65
-
66
- return undefined;
67
- }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
2
+
3
+ const mockGetAgent = jest.fn();
4
+ const mockResumeTask = jest.fn();
5
+ const mockGetRunStatus = jest.fn();
6
+
7
+ jest.mock("@axiom-lattice/core", () => ({
8
+ agentInstanceManager: {
9
+ getAgent: mockGetAgent,
10
+ },
11
+ ThreadStatus: {
12
+ IDLE: "IDLE",
13
+ BUSY: "BUSY",
14
+ INTERRUPTED: "INTERRUPTED",
15
+ },
16
+ }));
17
+
18
+ describe("recoverRun", () => {
19
+ let recoverRun: Function;
20
+
21
+ beforeEach(async () => {
22
+ jest.clearAllMocks();
23
+ const mod = await import("../../controllers/run");
24
+ recoverRun = mod.recoverRun;
25
+ });
26
+
27
+ it("should recover a thread and return its status", async () => {
28
+ const mockReply = {
29
+ status: jest.fn().mockReturnThis(),
30
+ send: jest.fn(),
31
+ };
32
+ const request = {
33
+ params: { assistantId: "a1", threadId: "t1" },
34
+ headers: {
35
+ "x-tenant-id": "tenant-x",
36
+ "x-workspace-id": "ws-1",
37
+ "x-project-id": "pj-1",
38
+ },
39
+ } as any;
40
+
41
+ const mockAgent = {
42
+ resumeTask: mockResumeTask.mockResolvedValue(undefined),
43
+ getRunStatus: mockGetRunStatus.mockResolvedValue("BUSY"),
44
+ };
45
+ mockGetAgent.mockReturnValue(mockAgent);
46
+
47
+ await recoverRun(request, mockReply);
48
+
49
+ expect(mockGetAgent).toHaveBeenCalledWith({
50
+ assistant_id: "a1",
51
+ thread_id: "t1",
52
+ tenant_id: "tenant-x",
53
+ workspace_id: "ws-1",
54
+ project_id: "pj-1",
55
+ });
56
+ expect(mockResumeTask).toHaveBeenCalled();
57
+ expect(mockGetRunStatus).toHaveBeenCalled();
58
+ expect(mockReply.status).toHaveBeenCalledWith(200);
59
+ expect(mockReply.send).toHaveBeenCalledWith({
60
+ success: true,
61
+ threadId: "t1",
62
+ status: "BUSY",
63
+ });
64
+ });
65
+
66
+ it("should default workspace/project to default when headers missing", async () => {
67
+ const mockReply = {
68
+ status: jest.fn().mockReturnThis(),
69
+ send: jest.fn(),
70
+ };
71
+ const request = {
72
+ params: { assistantId: "a1", threadId: "t1" },
73
+ headers: { "x-tenant-id": "tenant-x" },
74
+ } as any;
75
+
76
+ const mockAgent = {
77
+ resumeTask: mockResumeTask.mockResolvedValue(undefined),
78
+ getRunStatus: mockGetRunStatus.mockResolvedValue("IDLE"),
79
+ };
80
+ mockGetAgent.mockReturnValue(mockAgent);
81
+
82
+ await recoverRun(request, mockReply);
83
+
84
+ expect(mockGetAgent).toHaveBeenCalledWith({
85
+ assistant_id: "a1",
86
+ thread_id: "t1",
87
+ tenant_id: "tenant-x",
88
+ workspace_id: "default",
89
+ project_id: "default",
90
+ });
91
+ });
92
+
93
+ it("should return 500 on error", async () => {
94
+ const mockReply = {
95
+ status: jest.fn().mockReturnThis(),
96
+ send: jest.fn(),
97
+ };
98
+ const request = {
99
+ params: { assistantId: "a1", threadId: "t1" },
100
+ headers: { "x-tenant-id": "tenant-x" },
101
+ } as any;
102
+
103
+ mockGetAgent.mockImplementation(() => {
104
+ throw new Error("Agent not found");
105
+ });
106
+
107
+ await recoverRun(request, mockReply);
108
+
109
+ expect(mockReply.status).toHaveBeenCalledWith(500);
110
+ expect(mockReply.send).toHaveBeenCalledWith({
111
+ success: false,
112
+ error: "Recover failed: Agent not found",
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Tasks Controller 测试
3
+ *
4
+ * 测试任务 REST API 控制器的端点逻辑,包括权限控制和错误处理。
5
+ */
6
+
7
+ import { describe, expect, it, jest, beforeEach } from '@jest/globals';
8
+
9
+ const mockStore = {
10
+ create: jest.fn<(...args: unknown[]) => Promise<unknown>>(),
11
+ list: jest.fn<(...args: unknown[]) => Promise<unknown>>(),
12
+ update: jest.fn<(...args: unknown[]) => Promise<unknown>>(),
13
+ delete: jest.fn<(...args: unknown[]) => Promise<unknown>>(),
14
+ getById: jest.fn<(...args: unknown[]) => Promise<unknown>>(),
15
+ };
16
+
17
+ jest.mock('@axiom-lattice/core', () => ({
18
+ getStoreLattice: jest.fn(() => ({ store: mockStore })),
19
+ }));
20
+
21
+ describe('Tasks Controller', () => {
22
+ let listTasks: Function;
23
+ let getTask: Function;
24
+ let createTask: Function;
25
+ let updateTask: Function;
26
+ let deleteTask: Function;
27
+ let completeTask: Function;
28
+
29
+ const mockReply = () =>
30
+ ({
31
+ status: jest.fn().mockReturnThis(),
32
+ send: jest.fn(),
33
+ }) as any;
34
+
35
+ const makeRequest = (overrides: Record<string, any> = {}) =>
36
+ ({
37
+ headers: { 'x-tenant-id': 'tenant-1', 'x-user-id': 'user-1' },
38
+ query: {},
39
+ ...overrides,
40
+ }) as any;
41
+
42
+ beforeEach(async () => {
43
+ jest.clearAllMocks();
44
+ const mod = await import('../../controllers/tasks');
45
+ listTasks = mod.listTasks;
46
+ getTask = mod.getTask;
47
+ createTask = mod.createTask;
48
+ updateTask = mod.updateTask;
49
+ deleteTask = mod.deleteTask;
50
+ completeTask = mod.completeTask;
51
+ });
52
+
53
+ describe('listTasks', () => {
54
+ it('should return tasks for current user by default', async () => {
55
+ mockStore.list.mockResolvedValue([{ id: 't1', title: 'Test' }]);
56
+ const result = await listTasks(makeRequest(), mockReply());
57
+ expect(result.success).toBe(true);
58
+ expect(result.data).toHaveLength(1);
59
+ expect(mockStore.list).toHaveBeenCalledWith(
60
+ expect.objectContaining({ tenantId: 'tenant-1', ownerId: 'user-1' })
61
+ );
62
+ });
63
+
64
+ it('should filter by ownerId from query params', async () => {
65
+ mockStore.list.mockResolvedValue([]);
66
+ await listTasks(
67
+ makeRequest({ query: { ownerId: 'other-user' } }),
68
+ mockReply()
69
+ );
70
+ expect(mockStore.list).toHaveBeenCalledWith(
71
+ expect.objectContaining({ ownerId: 'other-user' })
72
+ );
73
+ });
74
+
75
+ it('should return error when something fails', async () => {
76
+ mockStore.list.mockRejectedValue(new Error('DB down'));
77
+ const result = await listTasks(makeRequest(), mockReply());
78
+ expect(result.success).toBe(false);
79
+ expect(result.error).toContain('DB down');
80
+ });
81
+ });
82
+
83
+ describe('getTask', () => {
84
+ it('should return task by id', async () => {
85
+ const task = { id: 't1', tenantId: 'tenant-1', ownerType: 'user', ownerId: 'user-1', title: 'T' };
86
+ mockStore.getById.mockResolvedValue(task);
87
+ const result = await getTask(makeRequest({ params: { id: 't1' } }), mockReply());
88
+ expect(result.success).toBe(true);
89
+ expect(result.data).toEqual(task);
90
+ });
91
+
92
+ it('should return 404 for non-existent task', async () => {
93
+ mockStore.getById.mockResolvedValue(null);
94
+ const reply = mockReply();
95
+ await getTask(makeRequest({ params: { id: 'nope' } }), reply);
96
+ expect(reply.status).toHaveBeenCalledWith(404);
97
+ expect(reply.send).toHaveBeenCalledWith(
98
+ expect.objectContaining({ success: false, error: 'Task not found' })
99
+ );
100
+ });
101
+
102
+ it('should return 403 for task owned by different user', async () => {
103
+ const task = { id: 't1', tenantId: 'tenant-1', ownerType: 'user', ownerId: 'other-user', title: 'T' };
104
+ mockStore.getById.mockResolvedValue(task);
105
+ const reply = mockReply();
106
+ await getTask(makeRequest({ params: { id: 't1' } }), reply);
107
+ expect(reply.status).toHaveBeenCalledWith(403);
108
+ });
109
+ });
110
+
111
+ describe('createTask', () => {
112
+ it('should create task with auto-filled owner', async () => {
113
+ const newTask = { id: 'new', title: 'Hello' };
114
+ mockStore.create.mockResolvedValue(newTask);
115
+ const reply = mockReply();
116
+ await createTask(makeRequest({ body: { title: 'Hello' } }), reply);
117
+ expect(mockStore.create).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ tenantId: 'tenant-1',
120
+ ownerType: 'user',
121
+ ownerId: 'user-1',
122
+ title: 'Hello',
123
+ })
124
+ );
125
+ expect(reply.status).toHaveBeenCalledWith(201);
126
+ expect(reply.send).toHaveBeenCalledWith(
127
+ expect.objectContaining({ success: true, data: newTask })
128
+ );
129
+ });
130
+
131
+ it('should return 400 when title is missing', async () => {
132
+ const reply = mockReply();
133
+ await createTask(makeRequest({ body: {} }), reply);
134
+ expect(reply.status).toHaveBeenCalledWith(400);
135
+ expect(reply.send).toHaveBeenCalledWith(
136
+ expect.objectContaining({ success: false, error: 'title is required' })
137
+ );
138
+ });
139
+
140
+ it('should return 500 on store error', async () => {
141
+ mockStore.create.mockRejectedValue(new Error('Store error'));
142
+ const reply = mockReply();
143
+ await createTask(makeRequest({ body: { title: 'T' } }), reply);
144
+ expect(reply.status).toHaveBeenCalledWith(500);
145
+ });
146
+ });
147
+
148
+ describe('updateTask', () => {
149
+ const existingTask = { id: 't1', tenantId: 'tenant-1', ownerType: 'user', ownerId: 'user-1' };
150
+
151
+ it('should update task fields', async () => {
152
+ mockStore.getById.mockResolvedValue(existingTask);
153
+ mockStore.update.mockResolvedValue({ ...existingTask, title: 'Updated' });
154
+ const result = await updateTask(
155
+ makeRequest({ params: { id: 't1' }, body: { title: 'Updated' } }),
156
+ mockReply()
157
+ );
158
+ expect(result.success).toBe(true);
159
+ expect(mockStore.update).toHaveBeenCalledWith('tenant-1', 't1', { title: 'Updated' });
160
+ });
161
+
162
+ it('should return 404 for non-existent task', async () => {
163
+ mockStore.getById.mockResolvedValue(null);
164
+ const reply = mockReply();
165
+ await updateTask(makeRequest({ params: { id: 'nope' }, body: {} }), reply);
166
+ expect(reply.status).toHaveBeenCalledWith(404);
167
+ });
168
+
169
+ it('should return 403 for task owned by different user', async () => {
170
+ mockStore.getById.mockResolvedValue({ ...existingTask, ownerId: 'other-user' });
171
+ const reply = mockReply();
172
+ await updateTask(makeRequest({ params: { id: 't1' }, body: {} }), reply);
173
+ expect(reply.status).toHaveBeenCalledWith(403);
174
+ });
175
+ });
176
+
177
+ describe('deleteTask', () => {
178
+ const existingTask = { id: 't1', tenantId: 'tenant-1', ownerType: 'user', ownerId: 'user-1' };
179
+
180
+ it('should delete task and return success', async () => {
181
+ mockStore.getById.mockResolvedValue(existingTask);
182
+ mockStore.delete.mockResolvedValue(true);
183
+ const result = await deleteTask(makeRequest({ params: { id: 't1' } }), mockReply());
184
+ expect(result.success).toBe(true);
185
+ expect(result.message).toBe('Task deleted');
186
+ expect(mockStore.delete).toHaveBeenCalledWith('tenant-1', 't1');
187
+ });
188
+
189
+ it('should return 403 for task owned by different user', async () => {
190
+ mockStore.getById.mockResolvedValue({ ...existingTask, ownerId: 'other-user' });
191
+ const reply = mockReply();
192
+ await deleteTask(makeRequest({ params: { id: 't1' } }), reply);
193
+ expect(reply.status).toHaveBeenCalledWith(403);
194
+ });
195
+ });
196
+
197
+ describe('completeTask', () => {
198
+ const existingTask = { id: 't1', tenantId: 'tenant-1', ownerType: 'user', ownerId: 'user-1' };
199
+
200
+ it('should mark task as completed', async () => {
201
+ mockStore.getById.mockResolvedValue(existingTask);
202
+ mockStore.update.mockResolvedValue({ ...existingTask, status: 'completed' });
203
+ const result = await completeTask(makeRequest({ params: { id: 't1' } }), mockReply());
204
+ expect(result.success).toBe(true);
205
+ expect(mockStore.update).toHaveBeenCalledWith('tenant-1', 't1', { status: 'completed' });
206
+ });
207
+
208
+ it('should return 403 for task owned by different user', async () => {
209
+ mockStore.getById.mockResolvedValue({ ...existingTask, ownerId: 'other-user' });
210
+ const reply = mockReply();
211
+ await completeTask(makeRequest({ params: { id: 't1' } }), reply);
212
+ expect(reply.status).toHaveBeenCalledWith(403);
213
+ });
214
+ });
215
+ });
@@ -25,6 +25,8 @@ export const triggerAgentTask = async (
25
25
  request.body as TriggerAgentTaskRequest;
26
26
 
27
27
  const tenant_id = request.headers["x-tenant-id"] as string;
28
+ const workspace_id = request.headers["x-workspace-id"] as string | undefined;
29
+ const project_id = request.headers["x-project-id"] as string | undefined;
28
30
 
29
31
  // Validate required fields
30
32
  if (!assistant_id) {
@@ -51,6 +53,11 @@ export const triggerAgentTask = async (
51
53
  input,
52
54
  command,
53
55
  "x-tenant-id": tenant_id,
56
+ runConfig: {
57
+ workspaceId: workspace_id,
58
+ projectId: project_id,
59
+ user_id: (request as any).user?.id,
60
+ },
54
61
  });
55
62
 
56
63
  reply.status(200).send({