Skip to content

Commit 32cb10c

Browse files
Anarchidclaude
andcommitted
fix(mcpl): stopTyping symmetry + immediate typing-metadata refresh
Review findings on anima-research#26: - stopTyping(channelId) no longer dispatches a 'stop' notification when there was no active interval. Matches the global-clear branch's semantics and keeps defensive stopTyping(ch) calls from spamming stops at a server that never saw a start. - startTyping(channelId, metadata) on an already-typing channel now dispatches an immediate refresh when the new metadata differs from the cached one, instead of waiting up to TYPING_INTERVAL_MS for the next tick. This is precisely the Zulip topic-switch case the op: 'stop' commit was meant to cover; the fast-path refresh uses the same routing-hint contract without the stop/start flap. Also adds a comment on the reconnect stderr rebind acknowledging that it inherits the same "transfer internals, leak listeners on the dead instance" pattern as the surrounding message-routing and lifecycle rewiring — a TODO for whoever cleans up that reconnect shape. Tests: adds test/channel-registry-typing.test.ts (6 cases) covering both symmetry and the metadata-change fast-path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0452663 commit 32cb10c

3 files changed

Lines changed: 183 additions & 9 deletions

File tree

src/mcpl/channel-registry.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js
3838

3939
const TYPING_INTERVAL_MS = 7_000;
4040

41+
function shallowEqualRecord(
42+
a: Record<string, unknown> | undefined,
43+
b: Record<string, unknown> | undefined,
44+
): boolean {
45+
if (a === b) return true;
46+
if (!a || !b) return false;
47+
const keysA = Object.keys(a);
48+
const keysB = Object.keys(b);
49+
if (keysA.length !== keysB.length) return false;
50+
for (const k of keysA) {
51+
if (a[k] !== b[k]) return false;
52+
}
53+
return true;
54+
}
55+
4156
// ============================================================================
4257
// Internal Types
4358
// ============================================================================
@@ -428,12 +443,26 @@ export class ChannelRegistry {
428443
* No-op if already typing on this channel.
429444
*/
430445
startTyping(channelId: string, metadata?: Record<string, unknown>): void {
446+
const metadataChanged =
447+
metadata !== undefined &&
448+
!shallowEqualRecord(this.typingMetadata.get(channelId), metadata);
431449
if (metadata) {
432450
this.typingMetadata.set(channelId, metadata);
433451
}
434452

435453
if (this.typingIntervals.has(channelId)) {
436-
return; // Already typing — metadata update above still took effect
454+
// Already typing. If the caller supplied new routing metadata (e.g. the
455+
// relevant Zulip topic just moved because a newer message arrived),
456+
// dispatch an immediate refresh so the server sees the new routing
457+
// within this request instead of waiting up to TYPING_INTERVAL_MS for
458+
// the next tick.
459+
if (metadataChanged) {
460+
const entry = this.findChannelEntry(channelId);
461+
if (entry) {
462+
this.sendTypingNotification(entry.serverId, channelId);
463+
}
464+
}
465+
return;
437466
}
438467

439468
// Find the channel entry and its server
@@ -467,14 +496,16 @@ export class ChannelRegistry {
467496
if (interval) {
468497
clearInterval(interval);
469498
this.typingIntervals.delete(channelId);
470-
}
471-
// Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
472-
// clear the indicator immediately rather than waiting for auto-expire.
473-
// Metadata still carries the routing hint so the stop hits the same
474-
// topic/thread as the start.
475-
const entry = this.findChannelEntry(channelId);
476-
if (entry && this.sendTypingFn) {
477-
this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
499+
// Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
500+
// clear the indicator immediately rather than waiting for auto-expire.
501+
// Metadata still carries the routing hint so the stop hits the same
502+
// topic/thread as the start. Guarded by `interval`: matches the
503+
// global-clear branch's semantics, and keeps defensive stopTyping(ch)
504+
// calls from spamming stops at a server that never saw a start.
505+
const entry = this.findChannelEntry(channelId);
506+
if (entry && this.sendTypingFn) {
507+
this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
508+
}
478509
}
479510
this.typingMetadata.delete(channelId);
480511
} else {

src/mcpl/server-connection.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,15 @@ export class McplServerConnection extends EventEmitter {
560560
this.pendingRequests.clear();
561561

562562
// Re-target stderr forwarding from the throwaway `fresh` instance to `this`.
563+
//
564+
// Note: this follows the same "transfer internals, leak listeners on the
565+
// dead instance" pattern as the message-routing and lifecycle rewiring
566+
// below — `fresh`'s own readline 'line' handler and process 'exit'
567+
// handler stay registered on the transferred child, short-circuited by
568+
// `fresh.closed = true` set in extractInternals(). Rebinding fresh's
569+
// stderr listener (rather than removing it) keeps that invariant: if we
570+
// ever clean up the message-routing duplication, fresh's stderr data
571+
// listener closure-holds onLine and would need the same teardown.
563572
if (fresh.rebindStderr) {
564573
fresh.rebindStderr((line: string) => this.emit('stderr', { line }));
565574
this.rebindStderr = fresh.rebindStderr;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { ChannelRegistry } from '../src/mcpl/channel-registry.js';
4+
import type { McplServerRegistry } from '../src/mcpl/server-registry.js';
5+
import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js';
6+
7+
type TypingCall = {
8+
serverId: string;
9+
channelId: string;
10+
metadata?: Record<string, unknown>;
11+
op?: 'start' | 'stop';
12+
};
13+
14+
function makeRegistry() {
15+
const calls: TypingCall[] = [];
16+
const sendTypingFn = (
17+
serverId: string,
18+
channelId: string,
19+
metadata?: Record<string, unknown>,
20+
op?: 'start' | 'stop',
21+
) => {
22+
calls.push({ serverId, channelId, metadata, op });
23+
};
24+
25+
const registry = new ChannelRegistry(
26+
{} as McplServerRegistry,
27+
{} as FeatureSetManager,
28+
() => {},
29+
() => {},
30+
{ sendTypingFn },
31+
);
32+
33+
// Register a channel directly via the private map — avoids the full
34+
// channel-incoming event plumbing, which isn't under test here.
35+
(registry as unknown as {
36+
channels: Map<string, { serverId: string; descriptor: { id: string }; open: boolean }>;
37+
}).channels.set('srv:ch1', {
38+
serverId: 'srv',
39+
descriptor: { id: 'ch1' },
40+
open: true,
41+
});
42+
43+
return { registry, calls };
44+
}
45+
46+
test('stopTyping(channelId) suppresses stop when no interval was active', () => {
47+
const { registry, calls } = makeRegistry();
48+
49+
// Never called startTyping — defensive stop should not dispatch anything.
50+
registry.stopTyping('ch1');
51+
52+
assert.equal(calls.length, 0, 'no typing notifications should be sent when no interval exists');
53+
});
54+
55+
test('stopTyping(channelId) dispatches stop when an interval was active', () => {
56+
const { registry, calls } = makeRegistry();
57+
58+
registry.startTyping('ch1', { topic: 't1' });
59+
assert.equal(calls.length, 1);
60+
assert.equal(calls[0].op, undefined); // initial start — op defaults
61+
calls.length = 0;
62+
63+
registry.stopTyping('ch1');
64+
assert.equal(calls.length, 1);
65+
assert.equal(calls[0].op, 'stop');
66+
assert.deepEqual(calls[0].metadata, { topic: 't1' });
67+
68+
// Second stop on the same channel is a no-op — interval already cleared.
69+
registry.stopTyping('ch1');
70+
assert.equal(calls.length, 1, 'repeated stopTyping should not re-dispatch');
71+
});
72+
73+
test('startTyping with changed metadata mid-typing dispatches immediate refresh', () => {
74+
const { registry, calls } = makeRegistry();
75+
76+
registry.startTyping('ch1', { topic: 'old-topic' });
77+
assert.equal(calls.length, 1);
78+
assert.deepEqual(calls[0].metadata, { topic: 'old-topic' });
79+
calls.length = 0;
80+
81+
// Newer incoming message moves the relevant Zulip topic — caller bumps
82+
// startTyping with new routing metadata. Should dispatch immediately rather
83+
// than waiting up to 7s for the next tick.
84+
registry.startTyping('ch1', { topic: 'new-topic' });
85+
assert.equal(calls.length, 1, 'metadata change should trigger immediate notification');
86+
assert.deepEqual(calls[0].metadata, { topic: 'new-topic' });
87+
88+
registry.stopTyping('ch1');
89+
});
90+
91+
test('startTyping with unchanged metadata mid-typing does not dispatch', () => {
92+
const { registry, calls } = makeRegistry();
93+
94+
registry.startTyping('ch1', { topic: 't' });
95+
calls.length = 0;
96+
97+
registry.startTyping('ch1', { topic: 't' });
98+
assert.equal(calls.length, 0, 'no-op when metadata is unchanged');
99+
100+
registry.stopTyping('ch1');
101+
});
102+
103+
test('startTyping with no metadata mid-typing does not dispatch', () => {
104+
const { registry, calls } = makeRegistry();
105+
106+
registry.startTyping('ch1', { topic: 't' });
107+
calls.length = 0;
108+
109+
registry.startTyping('ch1');
110+
assert.equal(calls.length, 0, 'omitted metadata should not trigger a refresh');
111+
112+
registry.stopTyping('ch1');
113+
});
114+
115+
test('stopTyping() global branch only dispatches stops for channels with intervals', () => {
116+
const { registry, calls } = makeRegistry();
117+
118+
// Stash metadata without an interval — simulates a past start that missed
119+
// findChannelEntry (pre-registration race).
120+
(registry as unknown as { typingMetadata: Map<string, Record<string, unknown>> }).typingMetadata.set(
121+
'phantom-ch',
122+
{ foo: 1 },
123+
);
124+
125+
registry.startTyping('ch1', { topic: 't' });
126+
calls.length = 0;
127+
128+
registry.stopTyping();
129+
130+
// Exactly one stop for ch1; phantom-ch never had an interval.
131+
const stops = calls.filter((c) => c.op === 'stop');
132+
assert.equal(stops.length, 1);
133+
assert.equal(stops[0].channelId, 'ch1');
134+
});

0 commit comments

Comments
 (0)