mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
143 lines
5.1 KiB
JavaScript
143 lines
5.1 KiB
JavaScript
// Pure model for the composer message queue (lane-msg-queue).
|
|
//
|
|
// The DSH wire accepts exactly ONE in-flight prompt per session — a second
|
|
// session/prompt while a turn is running fails on the wire. So when the user
|
|
// hits Enter mid-turn we don't drop the text and we don't error: we park it
|
|
// in a per-session FIFO and auto-send the head when the turn completes.
|
|
//
|
|
// This module is the data structure only — no DOM, no wire. The renderer
|
|
// owns the "when to enqueue vs send" decision and the "drain on turn end"
|
|
// timing; this file just holds the queues and enforces their invariants so
|
|
// the ordering/isolation semantics can be locked in `node --test` without an
|
|
// Electron harness.
|
|
//
|
|
// Shape:
|
|
// queues: Map<sessionId, Array<{ id, text }>>
|
|
// Item ids are monotonic across the whole module (not per-session) so a
|
|
// chip's id is globally unique — the UI keys DOM nodes on it and never has
|
|
// to disambiguate by session.
|
|
//
|
|
// Invariants the renderer relies on:
|
|
// - FIFO: enqueue appends to the tail; drain pops the head.
|
|
// - promote(id) moves that item to the head (the "send next" affordance),
|
|
// so the very next drain sends it regardless of arrival order.
|
|
// - Per-session isolation: no operation on session A can read, mutate, or
|
|
// drain session B. A drain of an empty/unknown session returns null.
|
|
// - Empty / whitespace-only text never enqueues (returns null) — a blank
|
|
// chip is a trap the user can't tell apart from a real queued message.
|
|
|
|
'use strict'
|
|
|
|
function createMsgQueue() {
|
|
/** @type {Map<string, Array<{id:number,text:string}>>} */
|
|
const queues = new Map()
|
|
let _seq = 0
|
|
|
|
function nextId() { _seq += 1; return _seq }
|
|
|
|
function ensure(sessionId) {
|
|
let q = queues.get(sessionId)
|
|
if (!q) { q = []; queues.set(sessionId, q) }
|
|
return q
|
|
}
|
|
|
|
// Append `text` to sessionId's queue. Returns the new item id, or null if
|
|
// the text is empty/whitespace-only (never enqueue a blank).
|
|
function enqueue(sessionId, text) {
|
|
if (sessionId == null) return null
|
|
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
if (!trimmed) return null
|
|
const id = nextId()
|
|
ensure(sessionId).push({ id, text: trimmed })
|
|
return id
|
|
}
|
|
|
|
// Drop the item with `id` from sessionId's queue. Returns true if removed.
|
|
function remove(sessionId, id) {
|
|
const q = queues.get(sessionId)
|
|
if (!q) return false
|
|
const i = q.findIndex((it) => it.id === id)
|
|
if (i < 0) return false
|
|
q.splice(i, 1)
|
|
return true
|
|
}
|
|
|
|
// Rewrite the text of an existing queued item. Empty/whitespace text is
|
|
// rejected (returns false) — the caller should treat that as "delete" if
|
|
// it wants blank edits to remove the chip. Returns true on success.
|
|
function update(sessionId, id, text) {
|
|
const q = queues.get(sessionId)
|
|
if (!q) return false
|
|
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
if (!trimmed) return false
|
|
const it = q.find((x) => x.id === id)
|
|
if (!it) return false
|
|
it.text = trimmed
|
|
return true
|
|
}
|
|
|
|
// Move `id` to the head of its session queue (the "send next" affordance).
|
|
// No-op returning false if the item isn't found; a single-item or
|
|
// already-head move succeeds (idempotent) and returns true.
|
|
function promote(sessionId, id) {
|
|
const q = queues.get(sessionId)
|
|
if (!q) return false
|
|
const i = q.findIndex((it) => it.id === id)
|
|
if (i < 0) return false
|
|
if (i === 0) return true
|
|
const [it] = q.splice(i, 1)
|
|
q.unshift(it)
|
|
return true
|
|
}
|
|
|
|
// Pop and return the head item ({id,text}) of sessionId's queue, or null
|
|
// when the queue is empty/unknown. This is the single "send one on turn
|
|
// end" primitive — the renderer calls it exactly once per turn completion.
|
|
function drain(sessionId) {
|
|
const q = queues.get(sessionId)
|
|
if (!q || q.length === 0) return null
|
|
return q.shift()
|
|
}
|
|
|
|
// Return a shallow copy of sessionId's queue (safe to render from; callers
|
|
// can't mutate the live array). Empty array for an unknown session.
|
|
function list(sessionId) {
|
|
const q = queues.get(sessionId)
|
|
return q ? q.map((it) => ({ id: it.id, text: it.text })) : []
|
|
}
|
|
|
|
function size(sessionId) {
|
|
const q = queues.get(sessionId)
|
|
return q ? q.length : 0
|
|
}
|
|
|
|
// Empty one session's queue. Returns the number of items dropped.
|
|
function clear(sessionId) {
|
|
const q = queues.get(sessionId)
|
|
if (!q) return 0
|
|
const n = q.length
|
|
queues.delete(sessionId)
|
|
return n
|
|
}
|
|
|
|
// Empty every session's queue (runtime crash / profile switch — the old
|
|
// session ids are a different namespace against the restarted daemon, so
|
|
// holding their queued text would send it into the void). Returns total
|
|
// items dropped across all sessions.
|
|
function clearAll() {
|
|
let n = 0
|
|
for (const q of queues.values()) n += q.length
|
|
queues.clear()
|
|
return n
|
|
}
|
|
|
|
return { enqueue, remove, update, promote, drain, list, size, clear, clearAll }
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { createMsgQueue }
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.__dshMsgQueueModel = { createMsgQueue }
|
|
}
|