discoclaw 1.2.4 → 2.0.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.
Files changed (87) hide show
  1. package/.context/voice.md +30 -2
  2. package/.env.example +7 -3
  3. package/.env.example.full +13 -32
  4. package/README.md +1 -1
  5. package/dist/cli/dashboard.js +7 -1
  6. package/dist/cli/dashboard.test.js +0 -4
  7. package/dist/cli/init-wizard.js +4 -8
  8. package/dist/cli/init-wizard.test.js +4 -10
  9. package/dist/config.js +5 -38
  10. package/dist/config.test.js +8 -72
  11. package/dist/cron/executor.js +72 -1
  12. package/dist/dashboard/api/metrics.js +7 -0
  13. package/dist/dashboard/api/metrics.test.js +16 -0
  14. package/dist/dashboard/api/traces.js +14 -0
  15. package/dist/dashboard/api/traces.test.js +40 -0
  16. package/dist/dashboard/page.js +187 -8
  17. package/dist/dashboard/server.js +82 -19
  18. package/dist/dashboard/server.test.js +123 -10
  19. package/dist/discord/actions.js +112 -6
  20. package/dist/discord/actions.test.js +117 -1
  21. package/dist/discord/deferred-runner.js +306 -219
  22. package/dist/discord/help-command.js +1 -1
  23. package/dist/discord/message-coordinator.js +4 -36
  24. package/dist/discord/models-command.js +1 -1
  25. package/dist/discord/reaction-handler.js +83 -5
  26. package/dist/discord/reaction-handler.test.js +55 -0
  27. package/dist/discord/verify-push.js +31 -36
  28. package/dist/discord/verify-push.test.js +34 -6
  29. package/dist/discord/voice-command.js +1 -31
  30. package/dist/discord/voice-command.test.js +21 -259
  31. package/dist/discord/voice-status-command.js +3 -22
  32. package/dist/discord/voice-status-command.test.js +16 -124
  33. package/dist/discord-followup.test.js +133 -0
  34. package/dist/health/config-doctor.js +5 -27
  35. package/dist/health/config-doctor.test.js +1 -4
  36. package/dist/index.js +15 -28
  37. package/dist/observability/trace-store.js +56 -0
  38. package/dist/observability/trace-utils.js +31 -0
  39. package/dist/runtime/codex-cli.js +3 -2
  40. package/dist/runtime/codex-cli.test.js +33 -0
  41. package/dist/runtime/model-tiers.js +1 -1
  42. package/dist/runtime/model-tiers.test.js +9 -0
  43. package/dist/runtime/openai-tool-schemas.js +17 -0
  44. package/dist/runtime-overrides.js +2 -3
  45. package/dist/runtime-overrides.test.js +27 -193
  46. package/dist/tasks/store.js +10 -6
  47. package/dist/tasks/store.test.js +44 -0
  48. package/dist/tasks/task-action-executor.test.js +162 -50
  49. package/dist/tasks/task-action-mutations.js +22 -2
  50. package/dist/tasks/task-action-read-ops.js +7 -1
  51. package/dist/tasks/task-action-runner-types.js +19 -1
  52. package/dist/voice/audio-pipeline.js +183 -96
  53. package/dist/voice/audio-receiver.js +8 -0
  54. package/dist/voice/audio-receiver.test.js +16 -0
  55. package/dist/voice/conversation-buffer.js +16 -6
  56. package/dist/voice/providers/gemini-live-provider.js +481 -0
  57. package/dist/voice/providers/gemini-live-provider.test.js +834 -0
  58. package/dist/voice/providers/gemini-live-responder.js +267 -0
  59. package/dist/voice/providers/gemini-live-responder.test.js +615 -0
  60. package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
  61. package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
  62. package/dist/voice/providers/gemini-live-types.js +32 -0
  63. package/dist/voice/providers/gemini-tool-mapper.js +91 -0
  64. package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
  65. package/dist/voice/providers/index.js +3 -0
  66. package/dist/voice/voice-prompt-builder.js +26 -17
  67. package/dist/voice/voice-prompt-builder.test.js +16 -1
  68. package/docs/configuration.md +4 -9
  69. package/docs/official-docs.md +6 -9
  70. package/docs/runtime-switching.md +1 -1
  71. package/package.json +1 -1
  72. package/dist/voice/audio-pipeline.test.js +0 -619
  73. package/dist/voice/stt-deepgram.js +0 -154
  74. package/dist/voice/stt-deepgram.test.js +0 -275
  75. package/dist/voice/stt-factory.js +0 -42
  76. package/dist/voice/stt-factory.test.js +0 -45
  77. package/dist/voice/stt-openai.js +0 -156
  78. package/dist/voice/stt-openai.test.js +0 -281
  79. package/dist/voice/tts-cartesia.js +0 -169
  80. package/dist/voice/tts-cartesia.test.js +0 -228
  81. package/dist/voice/tts-deepgram.js +0 -84
  82. package/dist/voice/tts-deepgram.test.js +0 -220
  83. package/dist/voice/tts-factory.js +0 -52
  84. package/dist/voice/tts-factory.test.js +0 -53
  85. package/dist/voice/tts-openai.js +0 -70
  86. package/dist/voice/tts-openai.test.js +0 -138
  87. package/dist/voice/types.test.js +0 -84
@@ -1,229 +1,63 @@
1
1
  import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
2
  import os from 'node:os';
3
+ import path from 'node:path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
- import { loadOverrides, normalizeRuntimeOverrides, resolveOverridesPath, saveOverrides, } from './runtime-overrides.js';
5
+ import { loadOverrides, normalizeRuntimeOverrides, resolveOverridesPath, saveOverrides } from './runtime-overrides.js';
6
6
  async function tmpDir() {
7
7
  return fs.mkdtemp(path.join(os.tmpdir(), 'runtime-overrides-'));
8
8
  }
9
9
  describe('resolveOverridesPath', () => {
10
10
  it('uses configured data dir when provided', () => {
11
- const out = resolveOverridesPath('/var/lib/discoclaw', '/repo');
12
- expect(out).toBe(path.join('/var/lib/discoclaw', 'runtime-overrides.json'));
13
- });
14
- it('falls back to <projectRoot>/data when data dir is empty string', () => {
15
- const out = resolveOverridesPath('', '/repo');
16
- expect(out).toBe(path.join('/repo', 'data', 'runtime-overrides.json'));
11
+ expect(resolveOverridesPath('/var/lib/discoclaw', '/repo')).toBe('/var/lib/discoclaw/runtime-overrides.json');
17
12
  });
18
- it('falls back to <projectRoot>/data when data dir is undefined', () => {
19
- const out = resolveOverridesPath(undefined, '/repo');
20
- expect(out).toBe(path.join('/repo', 'data', 'runtime-overrides.json'));
13
+ it('falls back to <projectRoot>/data when data dir is absent', () => {
14
+ expect(resolveOverridesPath(undefined, '/repo')).toBe('/repo/data/runtime-overrides.json');
21
15
  });
22
16
  });
23
- describe('loadOverrides', () => {
17
+ describe('loadOverrides and saveOverrides', () => {
24
18
  const dirs = [];
25
19
  afterEach(async () => {
26
- for (const d of dirs) {
27
- await fs.rm(d, { recursive: true, force: true });
20
+ for (const dir of dirs) {
21
+ await fs.rm(dir, { recursive: true, force: true });
28
22
  }
29
23
  dirs.length = 0;
30
24
  });
31
- it('returns empty object when file does not exist', async () => {
32
- const dir = await tmpDir();
33
- dirs.push(dir);
34
- const result = await loadOverrides(path.join(dir, 'runtime-overrides.json'));
35
- expect(result).toEqual({});
36
- });
37
- it('loads ttsVoice from a valid file', async () => {
38
- const dir = await tmpDir();
39
- dirs.push(dir);
40
- const filePath = path.join(dir, 'runtime-overrides.json');
41
- await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 'aura-2-luna-en' }), 'utf-8');
42
- const result = await loadOverrides(filePath);
43
- expect(result).toEqual({ ttsVoice: 'aura-2-luna-en' });
44
- });
45
- it('returns empty object for corrupt JSON and calls onWarn', async () => {
46
- const dir = await tmpDir();
47
- dirs.push(dir);
48
- const filePath = path.join(dir, 'runtime-overrides.json');
49
- await fs.writeFile(filePath, 'not-valid-json', 'utf-8');
50
- const warnings = [];
51
- const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
52
- expect(result).toEqual({});
53
- expect(warnings).toHaveLength(1);
54
- expect(warnings[0]).toMatch(/corrupt JSON/);
55
- });
56
- it('returns empty object when JSON root is an array and calls onWarn', async () => {
57
- const dir = await tmpDir();
58
- dirs.push(dir);
59
- const filePath = path.join(dir, 'runtime-overrides.json');
60
- await fs.writeFile(filePath, JSON.stringify(['opus']), 'utf-8');
61
- const warnings = [];
62
- const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
63
- expect(result).toEqual({});
64
- expect(warnings).toHaveLength(1);
65
- expect(warnings[0]).toMatch(/not an object/);
66
- });
67
- it('returns empty object when JSON root is a primitive and calls onWarn', async () => {
68
- const dir = await tmpDir();
69
- dirs.push(dir);
70
- const filePath = path.join(dir, 'runtime-overrides.json');
71
- await fs.writeFile(filePath, JSON.stringify(42), 'utf-8');
72
- const warnings = [];
73
- const result = await loadOverrides(filePath, (msg) => warnings.push(msg));
74
- expect(result).toEqual({});
75
- expect(warnings).toHaveLength(1);
76
- expect(warnings[0]).toMatch(/not an object/);
77
- });
78
- it('silently drops unknown top-level fields (including legacy models key)', async () => {
79
- const dir = await tmpDir();
80
- dirs.push(dir);
81
- const filePath = path.join(dir, 'runtime-overrides.json');
82
- await fs.writeFile(filePath, JSON.stringify({ models: { chat: 'sonnet' }, unknownField: 'x' }), 'utf-8');
83
- const result = await loadOverrides(filePath);
84
- expect(result).toEqual({});
85
- });
86
- it('loads ttsVoice field correctly', async () => {
25
+ it('returns empty object when the file does not exist', async () => {
87
26
  const dir = await tmpDir();
88
27
  dirs.push(dir);
89
- const filePath = path.join(dir, 'runtime-overrides.json');
90
- await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 'aura-2-asteria-en' }), 'utf-8');
91
- const result = await loadOverrides(filePath);
92
- expect(result).toEqual({ ttsVoice: 'aura-2-asteria-en' });
28
+ await expect(loadOverrides(path.join(dir, 'runtime-overrides.json'))).resolves.toEqual({});
93
29
  });
94
- it('silently drops ttsVoice when it is not a string', async () => {
30
+ it('round-trips managed runtime overrides', async () => {
95
31
  const dir = await tmpDir();
96
32
  dirs.push(dir);
97
33
  const filePath = path.join(dir, 'runtime-overrides.json');
98
- await fs.writeFile(filePath, JSON.stringify({ ttsVoice: 42 }), 'utf-8');
99
- const result = await loadOverrides(filePath);
100
- expect(result).toEqual({});
101
- });
102
- it('loads voiceRuntime field correctly', async () => {
103
- const dir = await tmpDir();
104
- dirs.push(dir);
105
- const filePath = path.join(dir, 'runtime-overrides.json');
106
- await fs.writeFile(filePath, JSON.stringify({ voiceRuntime: 'gemini-api' }), 'utf-8');
107
- const result = await loadOverrides(filePath);
108
- expect(result).toEqual({ voiceRuntime: 'gemini-api' });
34
+ await saveOverrides(filePath, { voiceRuntime: 'claude-api', fastRuntime: 'codex-cli' });
35
+ await expect(loadOverrides(filePath)).resolves.toEqual({
36
+ voiceRuntime: 'claude-api',
37
+ fastRuntime: 'codex-cli',
38
+ });
109
39
  });
110
- it('silently drops voiceRuntime when it is not a string', async () => {
40
+ it('drops legacy ttsVoice while preserving unrelated keys', async () => {
111
41
  const dir = await tmpDir();
112
42
  dirs.push(dir);
113
43
  const filePath = path.join(dir, 'runtime-overrides.json');
114
- await fs.writeFile(filePath, JSON.stringify({ voiceRuntime: 123 }), 'utf-8');
115
- const result = await loadOverrides(filePath);
116
- expect(result).toEqual({});
44
+ await fs.writeFile(filePath, JSON.stringify({ customFlag: true, ttsVoice: 'old-voice', voiceRuntime: 'gemini-api' }), 'utf-8');
45
+ await saveOverrides(filePath, { fastRuntime: 'codex-cli' });
46
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf-8'));
47
+ expect(raw).toEqual({ customFlag: true, fastRuntime: 'codex-cli' });
117
48
  });
118
49
  });
119
50
  describe('normalizeRuntimeOverrides', () => {
120
- it('canonicalizes accepted voice runtime aliases', () => {
121
- const result = normalizeRuntimeOverrides({ voiceRuntime: 'Anthropic' });
122
- expect(result).toEqual({
123
- overrides: { voiceRuntime: 'claude-api' },
51
+ it('canonicalizes accepted runtime aliases', () => {
52
+ expect(normalizeRuntimeOverrides({ voiceRuntime: 'Anthropic', fastRuntime: 'claude_code' })).toEqual({
53
+ overrides: { voiceRuntime: 'claude-api', fastRuntime: 'claude-cli' },
124
54
  changed: true,
125
55
  });
126
56
  });
127
- it('canonicalizes accepted fast runtime aliases', () => {
128
- const result = normalizeRuntimeOverrides({ fastRuntime: 'claude_code' });
129
- expect(result).toEqual({
130
- overrides: { fastRuntime: 'claude-cli' },
131
- changed: true,
132
- });
133
- });
134
- it('leaves invalid or already-canonical runtime values unchanged', () => {
135
- const result = normalizeRuntimeOverrides({
136
- voiceRuntime: 'claude-api',
137
- fastRuntime: 'not-a-runtime',
138
- ttsVoice: 'aura-2-asteria-en',
139
- });
140
- expect(result).toEqual({
141
- overrides: {
142
- voiceRuntime: 'claude-api',
143
- fastRuntime: 'not-a-runtime',
144
- ttsVoice: 'aura-2-asteria-en',
145
- },
57
+ it('leaves invalid or canonical runtime values unchanged', () => {
58
+ expect(normalizeRuntimeOverrides({ voiceRuntime: 'claude-api', fastRuntime: 'not-a-runtime' })).toEqual({
59
+ overrides: { voiceRuntime: 'claude-api', fastRuntime: 'not-a-runtime' },
146
60
  changed: false,
147
61
  });
148
62
  });
149
63
  });
150
- describe('saveOverrides', () => {
151
- const dirs = [];
152
- afterEach(async () => {
153
- for (const d of dirs) {
154
- await fs.rm(d, { recursive: true, force: true });
155
- }
156
- dirs.length = 0;
157
- });
158
- it('writes overrides and reads them back correctly', async () => {
159
- const dir = await tmpDir();
160
- dirs.push(dir);
161
- const filePath = path.join(dir, 'runtime-overrides.json');
162
- await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en', voiceRuntime: 'claude-api' });
163
- const raw = await fs.readFile(filePath, 'utf-8');
164
- expect(JSON.parse(raw)).toEqual({ ttsVoice: 'aura-2-asteria-en', voiceRuntime: 'claude-api' });
165
- });
166
- it('writes ttsVoice and reads it back correctly', async () => {
167
- const dir = await tmpDir();
168
- dirs.push(dir);
169
- const filePath = path.join(dir, 'runtime-overrides.json');
170
- await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en' });
171
- const result = await loadOverrides(filePath);
172
- expect(result).toEqual({ ttsVoice: 'aura-2-asteria-en' });
173
- });
174
- it('round-trips ttsVoice + voiceRuntime through save and load', async () => {
175
- const dir = await tmpDir();
176
- dirs.push(dir);
177
- const filePath = path.join(dir, 'runtime-overrides.json');
178
- const original = {
179
- ttsVoice: 'aura-2-luna-en',
180
- voiceRuntime: 'gemini-api',
181
- };
182
- await saveOverrides(filePath, original);
183
- const result = await loadOverrides(filePath);
184
- expect(result).toEqual(original);
185
- });
186
- it('creates the parent directory when it does not exist', async () => {
187
- const dir = await tmpDir();
188
- dirs.push(dir);
189
- const filePath = path.join(dir, 'subdir', 'runtime-overrides.json');
190
- await saveOverrides(filePath, { voiceRuntime: 'claude-api' });
191
- const raw = await fs.readFile(filePath, 'utf-8');
192
- expect(JSON.parse(raw)).toEqual({ voiceRuntime: 'claude-api' });
193
- });
194
- it('leaves no tmp file behind after a successful write', async () => {
195
- const dir = await tmpDir();
196
- dirs.push(dir);
197
- const filePath = path.join(dir, 'runtime-overrides.json');
198
- await saveOverrides(filePath, { ttsVoice: 'aura-2-asteria-en' });
199
- const files = await fs.readdir(dir);
200
- const tmpFiles = files.filter((f) => f.includes('.tmp.'));
201
- expect(tmpFiles).toHaveLength(0);
202
- });
203
- it('overwrites an existing overrides file', async () => {
204
- const dir = await tmpDir();
205
- dirs.push(dir);
206
- const filePath = path.join(dir, 'runtime-overrides.json');
207
- await saveOverrides(filePath, { ttsVoice: 'voice-a' });
208
- await saveOverrides(filePath, { ttsVoice: 'voice-b' });
209
- const raw = await fs.readFile(filePath, 'utf-8');
210
- expect(JSON.parse(raw)).toEqual({ ttsVoice: 'voice-b' });
211
- });
212
- it('preserves unknown keys while replacing known runtime override fields', async () => {
213
- const dir = await tmpDir();
214
- dirs.push(dir);
215
- const filePath = path.join(dir, 'runtime-overrides.json');
216
- await fs.writeFile(filePath, JSON.stringify({ customFlag: true, voiceRuntime: 'claude-api', ttsVoice: 'voice-a' }), 'utf-8');
217
- await saveOverrides(filePath, { fastRuntime: 'openrouter' });
218
- const raw = await fs.readFile(filePath, 'utf-8');
219
- expect(JSON.parse(raw)).toEqual({ customFlag: true, fastRuntime: 'openrouter' });
220
- });
221
- it('writes an empty overrides object', async () => {
222
- const dir = await tmpDir();
223
- dirs.push(dir);
224
- const filePath = path.join(dir, 'runtime-overrides.json');
225
- await saveOverrides(filePath, {});
226
- const raw = await fs.readFile(filePath, 'utf-8');
227
- expect(JSON.parse(raw)).toEqual({});
228
- });
229
- });
@@ -227,14 +227,18 @@ export class TaskStore extends EventEmitter {
227
227
  if (!prev)
228
228
  throw new Error(`task not found: ${id}`);
229
229
  const now = new Date().toISOString();
230
+ const updateParams = params;
230
231
  const updated = {
231
232
  ...prev,
232
- ...(params.title !== undefined && { title: params.title }),
233
- ...(params.description !== undefined && { description: params.description }),
234
- ...(params.priority !== undefined && { priority: params.priority }),
235
- ...(params.status !== undefined && { status: params.status }),
236
- ...(params.owner !== undefined && { owner: params.owner }),
237
- ...(params.externalRef !== undefined && { external_ref: params.externalRef }),
233
+ ...(updateParams.title !== undefined && { title: updateParams.title }),
234
+ ...(updateParams.description !== undefined && { description: updateParams.description }),
235
+ ...(updateParams.priority !== undefined && { priority: updateParams.priority }),
236
+ ...(updateParams.status !== undefined && { status: updateParams.status }),
237
+ ...(updateParams.owner !== undefined && { owner: updateParams.owner }),
238
+ ...(updateParams.externalRef !== undefined && { external_ref: updateParams.externalRef }),
239
+ ...(updateParams.threadOriginGuild !== undefined && {
240
+ thread_origin_guild: updateParams.threadOriginGuild,
241
+ }),
238
242
  updated_at: now,
239
243
  };
240
244
  this.tasks.set(id, updated);
@@ -408,6 +408,50 @@ describe('TaskStore — persistence', () => {
408
408
  expect(loaded.close_reason).toBe('done');
409
409
  await fsp.default.unlink(path).catch(() => { });
410
410
  });
411
+ it('persists thread origin guild when update provides it', async () => {
412
+ const fsp = await import('node:fs/promises');
413
+ const path = '/tmp/discoclaw-test-store-thread-origin-guild.jsonl';
414
+ await fsp.default.unlink(path).catch(() => { });
415
+ const store1 = new TaskStore({ prefix: 'ws', persistPath: path });
416
+ const task = store1.create({ title: 'T' });
417
+ store1.update(task.id, {
418
+ externalRef: 'discord:123',
419
+ threadOriginGuild: 'guild-456',
420
+ });
421
+ await store1.flush();
422
+ const persisted = JSON.parse((await fsp.default.readFile(path, 'utf8')).trim());
423
+ expect(persisted.external_ref).toBe('discord:123');
424
+ expect(persisted.thread_origin_guild).toBe('guild-456');
425
+ const store2 = new TaskStore({ prefix: 'ws', persistPath: path });
426
+ await store2.load();
427
+ const loaded = store2.get(task.id);
428
+ expect(loaded?.thread_origin_guild).toBe('guild-456');
429
+ await fsp.default.unlink(path).catch(() => { });
430
+ });
431
+ it('keeps legacy tasks without thread origin guild readable and writable', async () => {
432
+ const fsp = await import('node:fs/promises');
433
+ const path = '/tmp/discoclaw-test-store-legacy-thread-origin-guild.jsonl';
434
+ await fsp.default.unlink(path).catch(() => { });
435
+ const legacyTask = {
436
+ id: 'ws-001',
437
+ title: 'Legacy task',
438
+ status: 'open',
439
+ external_ref: 'discord:123',
440
+ created_at: '2026-04-06T00:00:00.000Z',
441
+ updated_at: '2026-04-06T00:00:00.000Z',
442
+ };
443
+ await fsp.default.writeFile(path, `${JSON.stringify(legacyTask)}\n`, 'utf8');
444
+ const store = new TaskStore({ prefix: 'ws', persistPath: path });
445
+ await store.load();
446
+ const loaded = store.get('ws-001');
447
+ expect(loaded?.thread_origin_guild).toBeUndefined();
448
+ store.update('ws-001', { title: 'Legacy task updated' });
449
+ await store.flush();
450
+ const rewritten = JSON.parse((await fsp.default.readFile(path, 'utf8')).trim());
451
+ expect(rewritten.title).toBe('Legacy task updated');
452
+ expect(rewritten.thread_origin_guild).toBeUndefined();
453
+ await fsp.default.unlink(path).catch(() => { });
454
+ });
411
455
  it('is a no-op when no persistPath is configured', async () => {
412
456
  const store = new TaskStore({ prefix: 'ws' });
413
457
  store.create({ title: 'T' });
@@ -50,11 +50,11 @@ vi.mock('./task-sync-engine.js', () => {
50
50
  // ---------------------------------------------------------------------------
51
51
  function makeCtx() {
52
52
  return {
53
- guild: {},
53
+ guild: { id: 'guild-current' },
54
54
  client: {
55
55
  channels: {
56
56
  cache: {
57
- get: () => undefined,
57
+ get: vi.fn(() => undefined),
58
58
  },
59
59
  },
60
60
  },
@@ -62,8 +62,8 @@ function makeCtx() {
62
62
  messageId: 'msg-current',
63
63
  };
64
64
  }
65
- function makeStore() {
66
- const defaultTask = (id) => ({
65
+ function makeTask(id, overrides = {}) {
66
+ return {
67
67
  id,
68
68
  title: 'Test task',
69
69
  description: 'A test',
@@ -76,34 +76,81 @@ function makeStore() {
76
76
  comments: [],
77
77
  created_at: '2026-01-01T00:00:00Z',
78
78
  updated_at: '2026-01-01T00:00:00Z',
79
- });
79
+ ...overrides,
80
+ };
81
+ }
82
+ function makeStore(opts) {
83
+ const tasks = new Map((opts?.initialTasks ?? [
84
+ makeTask('ws-001', { title: 'First' }),
85
+ makeTask('ws-002', {
86
+ title: 'Second',
87
+ status: 'in_progress',
88
+ priority: 1,
89
+ external_ref: 'discord:222333444',
90
+ labels: [],
91
+ }),
92
+ ]).map((task) => [task.id, task]));
80
93
  return {
81
- get: vi.fn((id) => {
82
- if (id === 'ws-notfound')
83
- return undefined;
84
- return defaultTask(id);
94
+ get: vi.fn((id) => tasks.get(id)),
95
+ list: vi.fn((params) => (Array.from(tasks.values()).slice(0, params?.limit ?? 50))),
96
+ create: vi.fn((params) => {
97
+ const createTaskOverrides = typeof opts?.createTaskOverrides === 'function'
98
+ ? opts.createTaskOverrides(params)
99
+ : (opts?.createTaskOverrides ?? {});
100
+ const task = makeTask('ws-new', {
101
+ title: params.title,
102
+ description: params.description ?? '',
103
+ priority: params.priority ?? 2,
104
+ external_ref: '',
105
+ labels: params.labels ?? [],
106
+ ...createTaskOverrides,
107
+ });
108
+ tasks.set(task.id, task);
109
+ return task;
110
+ }),
111
+ update: vi.fn((id, params) => {
112
+ const prev = tasks.get(id);
113
+ if (!prev)
114
+ throw new Error(`task not found: ${id}`);
115
+ const updated = makeTask(id, {
116
+ ...prev,
117
+ ...(params.title !== undefined ? { title: params.title } : {}),
118
+ ...(params.description !== undefined ? { description: params.description } : {}),
119
+ ...(params.priority !== undefined ? { priority: params.priority } : {}),
120
+ ...(params.status !== undefined ? { status: params.status } : {}),
121
+ ...(params.owner !== undefined ? { owner: params.owner } : {}),
122
+ ...(params.externalRef !== undefined ? { external_ref: params.externalRef } : {}),
123
+ ...(params.threadOriginGuild !== undefined ? { thread_origin_guild: params.threadOriginGuild } : {}),
124
+ updated_at: '2026-01-02T00:00:00Z',
125
+ });
126
+ tasks.set(id, updated);
127
+ return updated;
128
+ }),
129
+ close: vi.fn((id) => {
130
+ const prev = tasks.get(id);
131
+ if (!prev)
132
+ throw new Error(`task not found: ${id}`);
133
+ const updated = makeTask(id, {
134
+ ...prev,
135
+ status: 'closed',
136
+ closed_at: '2026-01-02T00:00:00Z',
137
+ updated_at: '2026-01-02T00:00:00Z',
138
+ });
139
+ tasks.set(id, updated);
140
+ return updated;
141
+ }),
142
+ addLabel: vi.fn((id, label) => {
143
+ const prev = tasks.get(id);
144
+ if (!prev)
145
+ throw new Error(`task not found: ${id}`);
146
+ const updated = makeTask(id, {
147
+ ...prev,
148
+ labels: [...new Set([...(prev.labels ?? []), label])],
149
+ updated_at: '2026-01-02T00:00:00Z',
150
+ });
151
+ tasks.set(id, updated);
152
+ return updated;
85
153
  }),
86
- list: vi.fn(() => [
87
- { id: 'ws-001', title: 'First', status: 'open', priority: 2 },
88
- { id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
89
- ]),
90
- create: vi.fn((params) => ({
91
- id: 'ws-new',
92
- title: params.title,
93
- description: params.description ?? '',
94
- status: 'open',
95
- priority: params.priority ?? 2,
96
- issue_type: 'task',
97
- owner: '',
98
- external_ref: '',
99
- labels: params.labels ?? [],
100
- comments: [],
101
- created_at: '2026-01-01T00:00:00Z',
102
- updated_at: '2026-01-01T00:00:00Z',
103
- })),
104
- update: vi.fn((id) => defaultTask(id)),
105
- close: vi.fn((id) => ({ ...defaultTask(id), status: 'closed' })),
106
- addLabel: vi.fn((id) => defaultTask(id)),
107
154
  reload: vi.fn(async () => ({ added: [], updated: [], removed: [] })),
108
155
  };
109
156
  }
@@ -137,11 +184,19 @@ describe('TASK_ACTION_TYPES', () => {
137
184
  });
138
185
  });
139
186
  describe('executeTaskAction', () => {
140
- it('taskCreate returns created task summary', async () => {
187
+ it('taskCreate returns a real Discord thread URL and structured thread metadata', async () => {
141
188
  const result = await executeTaskAction({ type: 'taskCreate', title: 'New task', priority: 1 }, makeCtx(), makeTaskCtx());
189
+ const success = result;
142
190
  expect(result.ok).toBe(true);
143
- expect(result.summary).toContain('ws-new');
144
- expect(result.summary).toContain('New task');
191
+ expect(success.summary).toContain('ws-new');
192
+ expect(success.summary).toContain('New task');
193
+ expect(success.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-new');
194
+ expect(success.thread).toEqual({
195
+ externalRef: 'discord:thread-new',
196
+ threadId: 'thread-new',
197
+ threadGuildId: 'guild-current',
198
+ threadUrl: 'https://discord.com/channels/guild-current/thread-new',
199
+ });
145
200
  });
146
201
  it('taskCreate calls forumCountSync.requestUpdate', async () => {
147
202
  const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
@@ -157,28 +212,29 @@ describe('executeTaskAction', () => {
157
212
  createTaskThread.mockClear?.();
158
213
  const result = await executeTaskAction({ type: 'taskCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeTaskCtx());
159
214
  expect(result.ok).toBe(true);
215
+ expect(result.summary).not.toContain('Thread:');
216
+ expect(result.thread).toBeUndefined();
160
217
  expect(createTaskThread).not.toHaveBeenCalled();
161
218
  });
162
- it('taskCreate skips thread creation when task is already linked before direct lifecycle step', async () => {
219
+ it('taskCreate backfills missing origin guild data for already-linked tasks', async () => {
163
220
  const { createTaskThread } = await import('./thread-ops.js');
164
221
  createTaskThread.mockClear?.();
165
- const store = makeStore();
166
- store.get.mockImplementation((id) => ({
167
- id,
168
- title: 'Already linked',
169
- status: 'open',
170
- priority: 2,
171
- issue_type: 'task',
172
- owner: '',
173
- external_ref: 'discord:thread-existing',
174
- labels: ['feature'],
175
- comments: [],
176
- created_at: '2026-01-01T00:00:00Z',
177
- updated_at: '2026-01-01T00:00:00Z',
178
- }));
222
+ const store = makeStore({
223
+ createTaskOverrides: {
224
+ title: 'Already linked',
225
+ external_ref: 'discord:thread-existing',
226
+ },
227
+ });
179
228
  const result = await executeTaskAction({ type: 'taskCreate', title: 'Task already linked' }, makeCtx(), makeTaskCtx({ store: store }));
180
229
  expect(result.ok).toBe(true);
181
- expect(result.summary).toContain('thread linked');
230
+ expect(result.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-existing');
231
+ expect(result.thread).toEqual({
232
+ externalRef: 'discord:thread-existing',
233
+ threadId: 'thread-existing',
234
+ threadGuildId: 'guild-current',
235
+ threadUrl: 'https://discord.com/channels/guild-current/thread-existing',
236
+ });
237
+ expect(store.update).toHaveBeenCalledWith('ws-new', { threadOriginGuild: 'guild-current' });
182
238
  expect(createTaskThread).not.toHaveBeenCalled();
183
239
  });
184
240
  it('taskUpdate returns updated summary', async () => {
@@ -301,9 +357,65 @@ describe('executeTaskAction', () => {
301
357
  it('taskShow returns task details', async () => {
302
358
  const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx());
303
359
  expect(result.ok).toBe(true);
304
- expect(result.summary).toContain('Test task');
360
+ expect(result.summary).toContain('First');
305
361
  expect(result.summary).toContain('ws-001');
306
362
  });
363
+ it('taskShow shows External ref for linked tasks even without canonical thread URL data', async () => {
364
+ const store = makeStore({
365
+ initialTasks: [
366
+ makeTask('ws-001', {
367
+ title: 'Linked elsewhere',
368
+ external_ref: 'gh:123',
369
+ thread_origin_guild: undefined,
370
+ }),
371
+ ],
372
+ });
373
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
374
+ expect(result.ok).toBe(true);
375
+ expect(result.summary).toContain('External ref: gh:123');
376
+ expect(result.summary).not.toContain('Thread:');
377
+ expect(result.thread).toEqual({ externalRef: 'gh:123' });
378
+ });
379
+ it('taskShow only emits Thread when stored origin guild data exists', async () => {
380
+ const store = makeStore({
381
+ initialTasks: [
382
+ makeTask('ws-001', {
383
+ title: 'Thread-linked',
384
+ external_ref: 'discord:111222333',
385
+ thread_origin_guild: 'guild-stored',
386
+ }),
387
+ ],
388
+ });
389
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
390
+ expect(result.ok).toBe(true);
391
+ expect(result.summary).toContain('External ref: discord:111222333');
392
+ expect(result.summary).toContain('Thread: https://discord.com/channels/guild-stored/111222333');
393
+ expect(result.thread).toEqual({
394
+ externalRef: 'discord:111222333',
395
+ threadId: '111222333',
396
+ threadGuildId: 'guild-stored',
397
+ threadUrl: 'https://discord.com/channels/guild-stored/111222333',
398
+ });
399
+ });
400
+ it('taskShow does not perform live Discord lookups', async () => {
401
+ const { resolveTasksForum } = await import('./thread-ops.js');
402
+ resolveTasksForum.mockClear?.();
403
+ const ctx = makeCtx();
404
+ const cacheGet = ctx.client.channels.cache.get;
405
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, ctx, makeTaskCtx({
406
+ store: makeStore({
407
+ initialTasks: [
408
+ makeTask('ws-001', {
409
+ external_ref: 'discord:111222333',
410
+ thread_origin_guild: 'guild-stored',
411
+ }),
412
+ ],
413
+ }),
414
+ }));
415
+ expect(result.ok).toBe(true);
416
+ expect(resolveTasksForum).not.toHaveBeenCalled();
417
+ expect(cacheGet).not.toHaveBeenCalled();
418
+ });
307
419
  it('taskShow fails for unknown task', async () => {
308
420
  const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-notfound' }, makeCtx(), makeTaskCtx());
309
421
  expect(result.ok).toBe(false);
@@ -4,6 +4,7 @@ import { autoTagTask } from './auto-tag.js';
4
4
  import { taskThreadCache } from './thread-cache.js';
5
5
  import { resolveTaskId, resolveTaskService, scheduleRepairSync, } from './task-action-mutation-helpers.js';
6
6
  import { ensureCreatedTaskThreadLink, syncClosedTaskThread, syncUpdatedTaskThread, } from './task-action-thread-sync.js';
7
+ import { getTaskActionThreadMetadata } from './task-action-runner-types.js';
7
8
  /** Pre-computed set for filtering status names from tag candidates. */
8
9
  const STATUS_NAME_SET = new Set(TASK_STATUSES);
9
10
  /**
@@ -73,10 +74,29 @@ export async function handleTaskCreate(action, ctx, taskCtx) {
73
74
  if (needsRepairSync) {
74
75
  scheduleRepairSync(taskCtx, task.id, ctx);
75
76
  }
77
+ let linkedTask = taskCtx.store.get(task.id) ?? task;
78
+ const storedThread = getTaskActionThreadMetadata(linkedTask);
79
+ const linkedThreadId = storedThread?.threadId ?? (threadId || undefined);
80
+ const guildId = ctx.guild.id?.trim() || undefined;
81
+ if (linkedThreadId && guildId && !linkedTask.thread_origin_guild) {
82
+ try {
83
+ linkedTask = taskService.update(task.id, {
84
+ ...(storedThread ? {} : { externalRef: `discord:${linkedThreadId}` }),
85
+ threadOriginGuild: guildId,
86
+ });
87
+ }
88
+ catch (err) {
89
+ taskCtx.log?.warn({ err, taskId: task.id, threadId: linkedThreadId, guildId }, 'tasks:thread origin guild update failed');
90
+ }
91
+ }
92
+ const thread = getTaskActionThreadMetadata(linkedTask);
76
93
  taskThreadCache.invalidate();
77
94
  taskCtx.forumCountSync?.requestUpdate();
78
- const threadNote = threadId ? ' (thread linked)' : '';
79
- return { ok: true, summary: `Task ${task.id} created: "${task.title}"${threadNote}` };
95
+ const summary = [
96
+ `Task ${task.id} created: "${task.title}"`,
97
+ ...(thread?.threadUrl ? [`Thread: ${thread.threadUrl}`] : []),
98
+ ].join('\n');
99
+ return { ok: true, summary, ...(thread ? { thread } : {}) };
80
100
  }
81
101
  export async function handleTaskUpdate(action, ctx, taskCtx) {
82
102
  const taskId = resolveTaskId(action);