Skip to content

Commit 171fabe

Browse files
authored
refactor(gateway): extract chat user-turn assembly (#106689)
1 parent 4c7efd2 commit 171fabe

3 files changed

Lines changed: 500 additions & 218 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
GATEWAY_CLIENT_IDS,
4+
GATEWAY_CLIENT_MODES,
5+
type GatewayClientInfo,
6+
} from "../../../packages/gateway-protocol/src/client-info.js";
7+
import type { MsgContext } from "../../auto-reply/templating.js";
8+
import type { UserTurnInput } from "../../sessions/user-turn-transcript.js";
9+
import { applyChatSendManagedMediaFields, prepareChatSendUserTurn } from "./chat-send-user-turn.js";
10+
11+
function createUserTurnInputController() {
12+
const baseInput: UserTurnInput = {
13+
text: "raw message",
14+
timestamp: 1,
15+
idempotencyKey: "run-1:user",
16+
};
17+
let inputPromise = Promise.resolve(baseInput);
18+
return {
19+
controller: {
20+
baseInput,
21+
setInputPromise: (input: Promise<UserTurnInput>) => {
22+
inputPromise = input;
23+
},
24+
},
25+
readInput: () => inputPromise,
26+
};
27+
}
28+
29+
function createClientInfo(overrides: Partial<GatewayClientInfo> = {}): GatewayClientInfo {
30+
return {
31+
id: GATEWAY_CLIENT_IDS.CLI,
32+
version: "test",
33+
platform: "test",
34+
mode: GATEWAY_CLIENT_MODES.CLI,
35+
...overrides,
36+
};
37+
}
38+
39+
function createAttachments(
40+
overrides: Partial<{
41+
explicitOriginTargetsPlugin: boolean;
42+
mediaPathOffloadPaths: string[];
43+
mediaPathOffloadTypes: string[];
44+
mediaPathOffloadWorkspaceDir: string | undefined;
45+
parsedMessage: string;
46+
}> = {},
47+
) {
48+
return {
49+
explicitOriginTargetsPlugin: false,
50+
imageOrder: [],
51+
mediaPathOffloadPaths: [],
52+
mediaPathOffloadTypes: [],
53+
mediaPathOffloadWorkspaceDir: undefined,
54+
offloadedRefs: [],
55+
parsedImages: [],
56+
parsedMessage: "hello",
57+
prepareAttachmentsMs: undefined,
58+
...overrides,
59+
};
60+
}
61+
62+
describe("prepareChatSendUserTurn", () => {
63+
it("assembles command, provenance, sender, and origin facts", async () => {
64+
const { controller, readInput } = createUserTurnInputController();
65+
const prepared = prepareChatSendUserTurn({
66+
request: {
67+
clientInfo: createClientInfo({ displayName: "Gateway CLI" }),
68+
normalizedAttachments: [],
69+
suppressCommandInterpretation: false,
70+
systemInputProvenance: { kind: "internal_system", sourceTool: "test" },
71+
systemProvenanceReceipt: "[System receipt]",
72+
},
73+
session: {
74+
agentId: "main",
75+
clientRunId: "run-1",
76+
sessionKey: "agent:main:main",
77+
},
78+
admission: {
79+
originatingRoute: {
80+
originatingChannel: "discord",
81+
originatingTo: "channel:1",
82+
accountId: "account-1",
83+
messageThreadId: "thread-1",
84+
explicitDeliverRoute: true,
85+
},
86+
},
87+
attachments: createAttachments({ parsedMessage: "/status" }),
88+
client: null,
89+
logGateway: { warn: vi.fn() } as never,
90+
userTurn: controller,
91+
});
92+
93+
expect(prepared.ctx).toMatchObject({
94+
Body: "[System receipt]\n\n/status",
95+
BodyForAgent: "[System receipt]\n\n/status",
96+
BodyForCommands: "/status",
97+
RawBody: "/status",
98+
CommandSource: "text",
99+
CommandAuthorized: true,
100+
CommandTurn: {
101+
kind: "text-slash",
102+
source: "text",
103+
authorized: true,
104+
body: "/status",
105+
},
106+
InputProvenance: { kind: "internal_system", sourceTool: "test" },
107+
OriginatingChannel: "discord",
108+
OriginatingTo: "channel:1",
109+
AccountId: "account-1",
110+
MessageThreadId: "thread-1",
111+
ExplicitDeliverRoute: true,
112+
SenderId: GATEWAY_CLIENT_IDS.CLI,
113+
SenderName: "Gateway CLI",
114+
SenderUsername: "Gateway CLI",
115+
});
116+
expect(prepared.accountId).toBe("account-1");
117+
expect(prepared.isInternalTextSlashCommandTurn).toBe(true);
118+
expect(prepared.queuedFollowupOwnerKey).toBeUndefined();
119+
expect(prepared.replyOptionImages).toBeUndefined();
120+
await expect(prepared.pluginBoundMediaFieldsPromise).resolves.toEqual({});
121+
await expect(readInput()).resolves.toEqual(controller.baseInput);
122+
});
123+
124+
it("carries pre-staged media and device ownership without UI sender decoration", async () => {
125+
const { controller, readInput } = createUserTurnInputController();
126+
const prepared = prepareChatSendUserTurn({
127+
request: {
128+
clientInfo: createClientInfo({
129+
id: GATEWAY_CLIENT_IDS.CONTROL_UI,
130+
mode: GATEWAY_CLIENT_MODES.UI,
131+
}),
132+
normalizedAttachments: [{}],
133+
suppressCommandInterpretation: true,
134+
systemInputProvenance: undefined,
135+
systemProvenanceReceipt: undefined,
136+
},
137+
session: {
138+
agentId: "main",
139+
clientRunId: "run-1",
140+
sessionKey: "agent:main:main",
141+
},
142+
admission: {
143+
originatingRoute: {
144+
originatingChannel: "webchat",
145+
explicitDeliverRoute: false,
146+
},
147+
},
148+
attachments: createAttachments({
149+
mediaPathOffloadPaths: ["uploads/report.pdf"],
150+
mediaPathOffloadTypes: ["application/pdf"],
151+
mediaPathOffloadWorkspaceDir: "/workspace",
152+
}),
153+
client: {
154+
connId: "conn-1",
155+
connect: {
156+
device: { id: "device-1" },
157+
scopes: ["operator.admin"],
158+
caps: ["tool-events"],
159+
},
160+
} as never,
161+
logGateway: { warn: vi.fn() } as never,
162+
userTurn: controller,
163+
});
164+
165+
expect(prepared.ctx).toMatchObject({
166+
CommandAuthorized: false,
167+
CommandTurn: {
168+
kind: "normal",
169+
source: "message",
170+
authorized: false,
171+
body: "hello",
172+
},
173+
ApprovalReviewerDeviceId: "device-1",
174+
MediaPath: "uploads/report.pdf",
175+
MediaPaths: ["uploads/report.pdf"],
176+
MediaType: "application/pdf",
177+
MediaTypes: ["application/pdf"],
178+
MediaWorkspaceDir: "/workspace",
179+
MediaStaged: true,
180+
GatewayClientScopes: ["operator.admin"],
181+
GatewayClientCaps: ["tool-events"],
182+
});
183+
expect(prepared.ctx).not.toHaveProperty("SenderId");
184+
expect(prepared.queuedFollowupOwnerKey).toBe("device:device-1");
185+
await expect(readInput()).resolves.toEqual(controller.baseInput);
186+
});
187+
});
188+
189+
describe("applyChatSendManagedMediaFields", () => {
190+
it("fills missing staged fields without replacing pre-staged paths", () => {
191+
const ctx = {
192+
MediaStaged: true,
193+
MediaPath: "uploads/report.pdf",
194+
} as MsgContext;
195+
196+
applyChatSendManagedMediaFields(ctx, {
197+
MediaPath: "managed/image.png",
198+
MediaPaths: ["managed/image.png"],
199+
MediaType: "image/png",
200+
MediaTypes: ["image/png"],
201+
});
202+
203+
expect(ctx).toMatchObject({
204+
MediaPath: "uploads/report.pdf",
205+
MediaPaths: ["managed/image.png"],
206+
MediaType: "image/png",
207+
MediaTypes: ["image/png"],
208+
});
209+
});
210+
});

0 commit comments

Comments
 (0)