fix: improve RemotePromptRecord.ref type safety (#1041)

Split RemotePromptRecord into a discriminated union of PendingPromptRecord
(ref is undefined) and DispatchedPromptRecord (ref is required). This
makes the type system enforce that ref is always present after dispatch.

Also removes a redundant truthiness check on dispatch.ref in manager.ts,
since RemoteDispatchResult.ref is already non-optional.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-17 18:33:08 -06:00 committed by GitHub
parent 9201c0ce16
commit 6240926ab6
3 changed files with 34 additions and 9 deletions

View file

@ -80,11 +80,9 @@ export async function tryRemoteQuestions(
markPromptAnswered(prompt.id, answer);
// Best-effort acknowledgement gives remote users a visible receipt signal.
if (dispatch.ref) {
try {
await adapter.acknowledgeAnswer?.(dispatch.ref);
} catch { /* best-effort */ }
}
try {
await adapter.acknowledgeAnswer?.(dispatch.ref);
} catch { /* best-effort */ }
return {
content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }],

View file

@ -51,11 +51,15 @@ export function updatePromptRecord(
): RemotePromptRecord | null {
const current = readPromptRecord(id);
if (!current) return null;
const next: RemotePromptRecord = {
const merged = {
...current,
...updates,
updatedAt: Date.now(),
};
// After spreading, the merged object satisfies one of the union members
// but TypeScript can't prove it statically. The invariant is maintained
// by callers: once `ref` is set via markPromptDispatched it is never removed.
const next = merged as RemotePromptRecord;
writePromptRecord(next);
return next;
}

View file

@ -44,17 +44,16 @@ export interface RemoteAnswer {
export type RemotePromptStatus = "pending" | "answered" | "timed_out" | "failed" | "cancelled";
export interface RemotePromptRecord {
/** Shared fields present on every prompt record regardless of dispatch state. */
interface RemotePromptRecordBase {
version: 1;
id: string;
createdAt: number;
updatedAt: number;
status: RemotePromptStatus;
channel: RemoteChannel;
timeoutAt: number;
pollIntervalMs: number;
questions: RemoteQuestion[];
ref?: RemotePromptRef;
response?: RemoteAnswer;
lastPollAt?: number;
lastError?: string;
@ -63,6 +62,30 @@ export interface RemotePromptRecord {
};
}
/** Record before the prompt has been dispatched to a channel. */
export interface PendingPromptRecord extends RemotePromptRecordBase {
status: "pending";
ref?: undefined;
}
/** Record after the prompt has been dispatched (ref is always present). */
export interface DispatchedPromptRecord extends RemotePromptRecordBase {
status: RemotePromptStatus;
ref: RemotePromptRef;
}
/**
* A prompt record is either pre-dispatch (no ref) or post-dispatch (ref required).
*
* Narrow via `record.ref`:
* ```ts
* if (record.ref) {
* // DispatchedPromptRecord — ref is RemotePromptRef
* }
* ```
*/
export type RemotePromptRecord = PendingPromptRecord | DispatchedPromptRecord;
export interface RemoteDispatchResult {
ref: RemotePromptRef;
}