@addozhang/dsh-discord 0.2.3 → 0.4.0

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/lib/i18n.d.ts CHANGED
@@ -78,6 +78,7 @@ declare const zh: {
78
78
  mainlineSessionRejected: string;
79
79
  mainlineSessionUnknown: string;
80
80
  mainlinePromptRejected: string;
81
+ mainlineImageFailed: string;
81
82
  mainlinePromptUnknown: string;
82
83
  continuationRejected: string;
83
84
  continuationUnknown: string;
@@ -109,6 +110,19 @@ declare const zh: {
109
110
  modelNotInCatalog: string;
110
111
  modelInvalidReasoning: string;
111
112
  modelTypedParseFailed: string;
113
+ sessionResumeNeedsBoundChannel: string;
114
+ sessionResumeStarted: (threadId: string) => string;
115
+ sessionResumeAlreadyBound: (threadId: string) => string;
116
+ sessionResumeFailed: string;
117
+ sessionResumeAnchor: (title: string) => string;
118
+ sessionResumeControlChannel: string;
119
+ sessionResumeSubagent: string;
120
+ sessionResumeArchived: string;
121
+ sessionCandidateJustNow: string;
122
+ sessionCandidateMinutesAgo: (minutes: number) => string;
123
+ sessionCandidateHoursAgo: (hours: number) => string;
124
+ sessionCandidateDaysAgo: (days: number) => string;
125
+ sessionCandidateRunning: string;
112
126
  approvalRequired: (label: string) => string;
113
127
  };
114
128
  export type CopyTable = typeof zh;
package/lib/i18n.js CHANGED
@@ -84,6 +84,7 @@ const zh = {
84
84
  mainlineSessionRejected: '⚠️ DSH 拒绝了会话创建(工作区可能已失效);请稍后重试或重新 /project bind。',
85
85
  mainlineSessionUnknown: '⚠️ 会话创建结果未知;请到 DSH Web 确认后再重试,不会自动重复创建。',
86
86
  mainlinePromptRejected: '⚠️ DSH 拒绝了任务提交。',
87
+ mainlineImageFailed: '⚠️ 附件图片获取失败,任务未提交;请重试或改用文字描述。',
87
88
  mainlinePromptUnknown: '⚠️ 任务提交结果未知;为避免重复执行不会自动重发,请确认后重试。',
88
89
  continuationRejected: '⚠️ 消息提交被 DSH 拒绝。',
89
90
  continuationUnknown: '⚠️ 消息提交结果未知;不会自动重发,请确认后重试。',
@@ -118,6 +119,22 @@ const zh = {
118
119
  modelNotInCatalog: '⚠️ 该 provider/模型不在当前会话的目录中。',
119
120
  modelInvalidReasoning: '⚠️ 该推理强度对此模型无效。',
120
121
  modelTypedParseFailed: '⚠️ 模型需按 `provider/model` 格式填写,或留空进入交互式选择。',
122
+ // ── /session resume ──────────────────────────────────────────────────
123
+ sessionResumeNeedsBoundChannel: '⚠️ /session resume 需要在已绑定工作区的项目频道中使用。',
124
+ sessionResumeStarted: (threadId) => `✅ 会话已恢复到 <#${threadId}>——历史在 Web 界面查看,线程内直接续聊。`,
125
+ sessionResumeAlreadyBound: (threadId) => `该会话已在 <#${threadId}> 中。`,
126
+ sessionResumeFailed: '⚠️ 会话恢复失败,请稍后重试。',
127
+ sessionResumeAnchor: (title) => `📌 恢复会话:${title}`,
128
+ sessionResumeControlChannel: 'general 是控制频道,不承载会话——请到工作区频道使用 /session resume。',
129
+ sessionResumeSubagent: '⚠️ 这是 subagent 会话,不能恢复为顶层线程——可在 Web 界面查看。',
130
+ sessionResumeArchived: '⚠️ 该会话已归档,恢复后不会有任何运行——请在 Web 界面先取消归档。',
131
+ // Discord autocomplete choices carry no description field, so the age and
132
+ // running marker ride the candidate label (16.47).
133
+ sessionCandidateJustNow: '刚刚',
134
+ sessionCandidateMinutesAgo: (minutes) => `${String(minutes)} 分钟前`,
135
+ sessionCandidateHoursAgo: (hours) => `${String(hours)} 小时前`,
136
+ sessionCandidateDaysAgo: (days) => `${String(days)} 天前`,
137
+ sessionCandidateRunning: '运行中',
121
138
  // ── approval / question cards ────────────────────────────────────────
122
139
  approvalRequired: (label) => `Approval required — ${label}`,
123
140
  };
@@ -191,6 +208,7 @@ const en = {
191
208
  mainlineSessionRejected: '⚠️ DSH rejected the session creation (the workspace may be stale); try again later or re-run /project bind.',
192
209
  mainlineSessionUnknown: '⚠️ Session creation outcome unknown; confirm on the DSH web console before retrying. It will not be resubmitted automatically.',
193
210
  mainlinePromptRejected: '⚠️ DSH rejected the task submission.',
211
+ mainlineImageFailed: '⚠️ Could not fetch the attached image(s); the task was not submitted. Retry or describe it in text.',
194
212
  mainlinePromptUnknown: '⚠️ Task submission outcome unknown; it will not be resent automatically to avoid double execution. Confirm and retry.',
195
213
  continuationRejected: '⚠️ The message submission was rejected by DSH.',
196
214
  continuationUnknown: '⚠️ Message submission outcome unknown; it will not be resent automatically. Confirm and retry.',
@@ -223,6 +241,20 @@ const en = {
223
241
  modelNotInCatalog: "⚠️ That provider/model is not in this session's catalog.",
224
242
  modelInvalidReasoning: '⚠️ That reasoning effort is not valid for this model.',
225
243
  modelTypedParseFailed: '⚠️ The model must be `provider/model`, or left empty for the interactive cascade.',
244
+ // ── /session resume ──────────────────────────────────────────────────
245
+ sessionResumeNeedsBoundChannel: '⚠️ /session resume must run in a bound project channel.',
246
+ sessionResumeStarted: threadId => `✅ Session resumed into <#${threadId}> — full history lives in the web UI; continue in the thread.`,
247
+ sessionResumeAlreadyBound: threadId => `This session already lives in <#${threadId}>.`,
248
+ sessionResumeFailed: '⚠️ Resuming the session failed; try again later.',
249
+ sessionResumeAnchor: title => `📌 Resumed session: ${title}`,
250
+ sessionResumeControlChannel: 'general is the control channel and carries no sessions — use /session resume in a workspace channel.',
251
+ sessionResumeSubagent: '⚠️ That is a subagent session — it cannot be resumed as a top-level thread; view it in the web UI.',
252
+ sessionResumeArchived: '⚠️ That session is archived — a resumed one never runs; unarchive it in the web UI first.',
253
+ sessionCandidateJustNow: 'just now',
254
+ sessionCandidateMinutesAgo: minutes => `${String(minutes)}m ago`,
255
+ sessionCandidateHoursAgo: hours => `${String(hours)}h ago`,
256
+ sessionCandidateDaysAgo: days => `${String(days)}d ago`,
257
+ sessionCandidateRunning: 'running',
226
258
  // ── approval / question cards ────────────────────────────────────────
227
259
  approvalRequired: label => `Approval required — ${label}`,
228
260
  };
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_DISCORD_SETTINGS, DiscordSettingsSchema, installDiscordSettings, normalizeDiscordSettings, } from './settings.js';
2
2
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
3
3
  import { installCancellationRoot } from './lifecycle.js';
4
- import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EPHEMERAL_FLAGS } from './policy/disclosure.js';
4
+ import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EPHEMERAL_FLAGS, safeTitle } from './policy/disclosure.js';
5
5
  import { validateHostCapabilities } from './startup.js';
6
6
  import { createAdapterStatusTracker, installAdapterStatusRpc, } from './features/adapter-status.js';
7
7
  import { DISCORD_BOT_TOKEN_REF, describeDiscordCredential, resolveDiscordBotToken } from './credential.js';
@@ -22,14 +22,16 @@ import { createInteractionRouter } from './features/interaction-router.js';
22
22
  import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
23
23
  import { planBindingReconciliation } from './features/reconcile-bindings.js';
24
24
  import { guildKeysToForget, sweepExpired } from './state/retention.js';
25
- import { listSessionIds, createClientRespondPort } from './dsh/api-proxy-face.js';
25
+ import { listSessionIds, listSessionSummaries, createClientRespondPort } from './dsh/api-proxy-face.js';
26
26
  import { createBindingStore } from './state/bindings.js';
27
27
  import { createIntentStore } from './state/intents.js';
28
28
  import { createTurnTracker } from './features/turn-ownership.js';
29
29
  import { createThreadCreationFlow } from './features/thread-creation.js';
30
30
  import { createSessionCreationFlow } from './features/session-creation.js';
31
+ import { createResumeCandidatesPort } from './features/session-resume.js';
31
32
  import { createPromptSubmissionFlow } from './features/prompt-submission.js';
32
33
  import { createSessionMainline, requestIdFor } from './features/session-mainline.js';
34
+ import { collectImages, createSafeImageDownloadPort } from './features/image-collection.js';
33
35
  import { startLiveRender } from './stream/live.js';
34
36
  import { planWorkspaceChannel, workspaceChannelName } from './features/workspace-channel.js';
35
37
  /** Stable Cordis plugin name for diagnostics. */
@@ -285,7 +287,37 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
285
287
  createSession: request => createSessionViaProxy(apiProxy, request, { log: rpcLog }),
286
288
  };
287
289
  const dshPromptPort = {
288
- submit: request => promptSession(apiProxy, { sessionId: request.sessionId, prompt: request.prompt }, { log: rpcLog, rpcId: request.requestId }),
290
+ submit: request => promptSession(apiProxy, {
291
+ sessionId: request.sessionId,
292
+ prompt: request.prompt,
293
+ ...(request.images === undefined ? {} : { images: request.images }),
294
+ }, { log: rpcLog, rpcId: request.requestId }),
295
+ };
296
+ /**
297
+ * The bounded image collection boundary (16.50): downloads ride the
298
+ * safe-download port (allowlisted CDN hosts, capped sizes, timeout) and
299
+ * come back base64-encoded for the prompt parts.
300
+ */
301
+ const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
302
+ const imageCollector = {
303
+ collect: async ({ attachments }) => {
304
+ const result = await collectImages(createSafeImageDownloadPort(), {
305
+ nowMs: Date.now(),
306
+ timeoutMs: IMAGE_DOWNLOAD_TIMEOUT_MS,
307
+ attachments,
308
+ });
309
+ if (result.outcome !== 'collected') {
310
+ const reason = result.outcome === 'too-large' ? `too-large:${result.reason}` : result.outcome;
311
+ return { outcome: 'failed', reason };
312
+ }
313
+ return {
314
+ outcome: 'collected',
315
+ images: result.downloaded.map(image => ({
316
+ mediaType: image.mediaType,
317
+ base64: Buffer.from(image.body).toString('base64'),
318
+ })),
319
+ };
320
+ },
289
321
  };
290
322
  const composedMainline = createSessionMainline({
291
323
  threads: createThreadCreationFlow({
@@ -304,6 +336,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
304
336
  nowMs: () => Date.now(),
305
337
  }),
306
338
  turns: turnTracker,
339
+ images: imageCollector,
307
340
  });
308
341
  /** Mainline outcome → copy key; the string resolves live per language. */
309
342
  const MAINLINE_FAILURE_KEYS = {
@@ -313,6 +346,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
313
346
  'session-unknown': 'mainlineSessionUnknown',
314
347
  'prompt-rejected': 'mainlinePromptRejected',
315
348
  'prompt-unknown': 'mainlinePromptUnknown',
349
+ 'image-failed': 'mainlineImageFailed',
316
350
  };
317
351
  const mainline = {
318
352
  admitMention: async (request) => {
@@ -333,9 +367,13 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
333
367
  },
334
368
  continueInThread: async (request) => {
335
369
  const result = await composedMainline.continueInThread(request);
336
- if (result.outcome === 'rejected' || result.outcome === 'unknown') {
370
+ if (result.outcome === 'rejected' || result.outcome === 'unknown' || result.outcome === 'image-failed') {
337
371
  rpcLog('discord_continuation_not_queued', { messageId: request.messageId, outcome: result.outcome });
338
- const notice = result.outcome === 'rejected' ? copy.continuationRejected : copy.continuationUnknown;
372
+ const notice = result.outcome === 'rejected'
373
+ ? copy.continuationRejected
374
+ : result.outcome === 'image-failed'
375
+ ? copy.mainlineImageFailed
376
+ : copy.continuationUnknown;
339
377
  void withRest(rest => rest.request('POST', `/channels/${request.threadId}/messages`, { content: notice, flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, allowed_mentions: ALLOWED_MENTIONS_NONE }))
340
378
  .catch((cause) => { rpcLog('discord_mainline_notice_failed', String(cause)); });
341
379
  }
@@ -702,6 +740,91 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
702
740
  },
703
741
  model: createModelPort(apiProxy, { log: rpcLog }),
704
742
  modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
743
+ resumeCandidates: createResumeCandidatesPort({
744
+ listSessions: () => listSessionSummaries(apiProxy, { log: rpcLog }),
745
+ boundSessionIds: () => {
746
+ const bound = new Set();
747
+ for (const [, record] of threadTable.entries())
748
+ bound.add(record.sessionId);
749
+ return bound;
750
+ },
751
+ copy,
752
+ }),
753
+ resumeSession: async ({ sessionId, workspaceId, guildId, parentChannelId, actorId }) => {
754
+ const rest = await sharedRest();
755
+ if (rest === undefined)
756
+ return { outcome: 'failed' };
757
+ // threadBindings is the Discord-ownership record: a session with a
758
+ // binding already lives in a thread (16.44; the owners-store wiring
759
+ // stays deferred per 16.28).
760
+ const existingThread = threadForSession(sessionId);
761
+ if (existingThread !== undefined)
762
+ return { outcome: 'already-bound', threadId: existingThread };
763
+ // The control channel never carries sessions: refuse explicitly so
764
+ // the user is pointed at the workspace channel (16.44 rev).
765
+ const listedChannels = await rest.request('GET', `/guilds/${guildId}/channels`);
766
+ if (listedChannels.outcome === 'completed') {
767
+ const category = listedChannels.body.find(channel => channel.type === 4 && channel.name.toLowerCase() === CATEGORY_NAME.toLowerCase());
768
+ const control = category !== undefined
769
+ ? listedChannels.body.find(channel => channel.type === 0 && channel.parent_id === category.id && channel.name.toLowerCase() === 'general')
770
+ : undefined;
771
+ if (control !== undefined && control.id === parentChannelId) {
772
+ return { outcome: 'refused-control-channel' };
773
+ }
774
+ }
775
+ const summaries = await listSessionSummaries(apiProxy, { log: rpcLog });
776
+ if (summaries.outcome !== 'completed')
777
+ return { outcome: 'failed' };
778
+ const summary = summaries.sessions.find(candidate => candidate.sessionId === sessionId);
779
+ if (summary === undefined || summary.blank)
780
+ return { outcome: 'failed' };
781
+ // Spec scenario "Subagent Session selected": a subagent spawn is
782
+ // never a top-level writable thread, even if handed in directly
783
+ // (the candidates already hide it — 16.48).
784
+ if (summary.origin === 'subagent')
785
+ return { outcome: 'refused-subagent' };
786
+ // Same defense for archived sessions (16.49): adoption would
787
+ // succeed and the thread would never see a turn.
788
+ const catalog = await catalogPort.listWorkspaces();
789
+ if (catalog.outcome === 'completed'
790
+ && catalog.archivedSessionIds.includes(sessionId)) {
791
+ return { outcome: 'refused-archived' };
792
+ }
793
+ const title = summary.title ?? `Resume ${sessionId.slice(0, 8)}`;
794
+ // Anchor post: a durable marker the resume thread hangs off.
795
+ const anchor = await rest.request('POST', `/channels/${parentChannelId}/messages`, {
796
+ content: copy.sessionResumeAnchor(safeTitle(title)),
797
+ flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
798
+ allowed_mentions: ALLOWED_MENTIONS_NONE,
799
+ });
800
+ if (anchor.outcome === 'rejected') {
801
+ rpcLog('discord_session_resume_anchor_failed', { sessionId, status: `HTTP ${String(anchor.status)}` });
802
+ return { outcome: 'failed' };
803
+ }
804
+ if (anchor.outcome === 'unknown' || typeof anchor.body.id !== 'string') {
805
+ rpcLog('discord_session_resume_anchor_unknown', { sessionId });
806
+ return { outcome: 'failed' };
807
+ }
808
+ const thread = await rest.request('POST', `/channels/${parentChannelId}/messages/${anchor.body.id}/threads`, {
809
+ name: safeTitle(title),
810
+ });
811
+ if (thread.outcome === 'rejected') {
812
+ rpcLog('discord_session_resume_thread_failed', { sessionId, status: `HTTP ${String(thread.status)}` });
813
+ return { outcome: 'failed' };
814
+ }
815
+ if (thread.outcome === 'unknown' || typeof thread.body.id !== 'string') {
816
+ rpcLog('discord_session_resume_thread_unknown', { sessionId });
817
+ return { outcome: 'failed' };
818
+ }
819
+ await threadBindingStore.bind(threadBindingKey({ applicationId: applicationIdRef.current, guildId, threadId: thread.body.id }), {
820
+ sessionId,
821
+ workspaceId,
822
+ createdBy: actorId,
823
+ createdAtMs: Date.now(),
824
+ });
825
+ rpcLog('discord_session_resumed', { sessionId, threadId: thread.body.id, title });
826
+ return { outcome: 'started', threadId: thread.body.id };
827
+ },
705
828
  log: rpcLog,
706
829
  warn: (event, detail) => {
707
830
  emitLog(ctx, 'warn', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addozhang/dsh-discord",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Discord-first adapter for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -91,5 +91,16 @@
91
91
  "bugs": {
92
92
  "url": "https://github.com/addozhang/dsh-discord/issues"
93
93
  },
94
- "homepage": "https://github.com/addozhang/dsh-discord#readme"
94
+ "homepage": "https://github.com/addozhang/dsh-discord#readme",
95
+ "keywords": [
96
+ "discord",
97
+ "deepseek",
98
+ "deepseek-harness",
99
+ "dsh",
100
+ "dsh-plugin",
101
+ "bot",
102
+ "ai-agent",
103
+ "claude-code",
104
+ "coding-agent"
105
+ ]
95
106
  }
@@ -1,67 +0,0 @@
1
- /**
2
- * Image submission through `session.prompt` (design.md §12, task 12.3).
3
- * Mixed text/images encode into ordered prompt parts; the model's modality
4
- * is checked BEFORE any DSH call; and the request id claims an intent so a
5
- * duplicate Discord delivery never resubmits. Host outcomes map to values
6
- * with the intent state kept honest (failed on rejection, unknown preserved
7
- * for reconciliation).
8
- */
9
- import { type IntentStore } from '../state/intents.js';
10
- export type PromptPart = {
11
- type: 'text';
12
- text: string;
13
- } | {
14
- type: 'image';
15
- mediaType: string;
16
- data: string;
17
- };
18
- export interface DshImagePromptPort {
19
- submit(request: {
20
- requestId: string;
21
- sessionId: string;
22
- parts: readonly PromptPart[];
23
- }): Promise<{
24
- outcome: 'accepted';
25
- } | {
26
- outcome: 'rejected';
27
- } | {
28
- outcome: 'unknown';
29
- }>;
30
- }
31
- export interface ImageSubmissionDeps {
32
- prompts: DshImagePromptPort;
33
- intents: IntentStore;
34
- /** Whether the session's current model accepts image parts. */
35
- modality: () => boolean;
36
- nowMs: () => number;
37
- }
38
- export type ImageSubmissionResult = {
39
- outcome: 'accepted';
40
- } | {
41
- outcome: 'rejected';
42
- } | {
43
- outcome: 'unknown';
44
- } | {
45
- outcome: 'already-submitted';
46
- } | {
47
- outcome: 'conflict';
48
- } | {
49
- outcome: 'refused';
50
- reason: 'unsupported-modality';
51
- };
52
- /** Encode text plus images into ordered prompt parts. */
53
- export declare function encodePromptParts(text: string, images: ReadonlyArray<{
54
- mediaType: string;
55
- base64: string;
56
- }>): PromptPart[];
57
- export declare function createImageSubmissionFlow(deps: ImageSubmissionDeps): {
58
- submit(request: {
59
- requestId: string;
60
- sessionId: string;
61
- text: string;
62
- images: ReadonlyArray<{
63
- mediaType: string;
64
- base64: string;
65
- }>;
66
- }): Promise<ImageSubmissionResult>;
67
- };
@@ -1,57 +0,0 @@
1
- /**
2
- * Image submission through `session.prompt` (design.md §12, task 12.3).
3
- * Mixed text/images encode into ordered prompt parts; the model's modality
4
- * is checked BEFORE any DSH call; and the request id claims an intent so a
5
- * duplicate Discord delivery never resubmits. Host outcomes map to values
6
- * with the intent state kept honest (failed on rejection, unknown preserved
7
- * for reconciliation).
8
- */
9
- import { hashPayload } from '../state/intents.js';
10
- /** Encode text plus images into ordered prompt parts. */
11
- export function encodePromptParts(text, images) {
12
- const parts = [];
13
- const trimmed = text.trim();
14
- if (trimmed !== '')
15
- parts.push({ type: 'text', text: trimmed });
16
- for (const image of images) {
17
- parts.push({ type: 'image', mediaType: image.mediaType, data: image.base64 });
18
- }
19
- return parts;
20
- }
21
- export function createImageSubmissionFlow(deps) {
22
- return {
23
- async submit(request) {
24
- const parts = encodePromptParts(request.text, request.images);
25
- // Modality gate: refuse BEFORE the intent claim so an explicit user
26
- // retry with a text-only message is a clean new attempt.
27
- if (request.images.length > 0 && !deps.modality()) {
28
- return { outcome: 'refused', reason: 'unsupported-modality' };
29
- }
30
- const contentHash = await hashPayload({ sessionId: request.sessionId, parts });
31
- const claim = await deps.intents.claim({
32
- messageId: request.requestId,
33
- contentHash,
34
- claimedAtMs: deps.nowMs(),
35
- });
36
- if (claim.outcome === 'conflict')
37
- return { outcome: 'conflict' };
38
- if (claim.outcome === 'duplicate')
39
- return { outcome: 'already-submitted' };
40
- const submitted = await deps.prompts.submit({
41
- requestId: request.requestId,
42
- sessionId: request.sessionId,
43
- parts,
44
- });
45
- if (submitted.outcome === 'accepted') {
46
- await deps.intents.resolve(request.requestId, 'succeeded', deps.nowMs());
47
- return { outcome: 'accepted' };
48
- }
49
- if (submitted.outcome === 'rejected') {
50
- await deps.intents.resolve(request.requestId, 'failed', deps.nowMs());
51
- return { outcome: 'rejected' };
52
- }
53
- await deps.intents.resolve(request.requestId, 'unknown', deps.nowMs());
54
- return { outcome: 'unknown' };
55
- },
56
- };
57
- }