feat(gateway): 消息mode落库(A3) — 持久化真实写入渲染模式
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- POST /api/v1/chat 与 ws.chat 上行新增可选 mode(行为模式 design/01 §3/design/16 §5.1)
- Web路径经ctx透传本轮mode到编排器持久化(persistUserMessages/persistAssistantReply),缺省回落chat
- pendingMsg记录mode,恢复/续接路径沿用发起轮次mode
- IM路径(persistToMain/persistIsolated)恒为chat
- 新增/扩充测试:REST/WS/orchestrator/channelbridge 覆盖mode写入
- Docs/api-client.md v0.20 同步mode字段
This commit is contained in:
AskaEth committed 2026-09-08 21:05:50 +08:00
1 parent fe5dbcddba
commit b686d5fec8
9 files changed
+199 -41

No files matched your search

@@ -130,11 +130,14 @@ type sessionState struct {
// pendingMsg is one queued user turn. It carries its own env so a resume runs
// under the same memory-isolation boundary the message arrived with, plus the
// client deviceID of the originating turn (design/16 §4.5) so the resumed
// assistant reply is tagged with the same client that sent the message.
// assistant reply is tagged with the same client that sent the message, and the
// turn's behavior mode (design/16 §5.1) so the resumed reply is persisted under
// the same mode as the user message that started it.
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
deviceID string
mode string
at time.Time
}
@@ -219,6 +222,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
o.states[sessionID] = st
}
deviceID := deviceIDFrom(ctx)
msgMode := modeFrom(ctx)
if st.mode != ModeIdle {
if len(st.pending) >= o.maxPending {
mode := st.mode
@@ -226,11 +230,11 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": mode, "max": o.maxPending})
return SubmitResult{PendingFull: true}, nil
}
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, at: time.Now()})
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now()})
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
o.persistUserMessages(ctx, sessionID, messages, deviceID)
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode)
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
}
@@ -244,32 +248,32 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
o.mu.Unlock()
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
o.persistUserMessages(ctx, sessionID, messages, deviceID)
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode)
if sync {
res, err := o.run(runCtx, sessionID, messages, env)
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err, deviceID)
_ = o.completeTurn(sessionID, gen, res, err, deviceID, msgMode)
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
}
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel, deviceID)
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel, deviceID, msgMode)
return SubmitResult{Accepted: true, Immediate: true}, nil
}
// goTurn runs a single turn on a background goroutine and, when it finishes,
// hands the outcome to completeTurn so the orchestrator can resume the queue.
// deviceID tags the persistent assistant reply with the client that sent the turn
// (design/16 §4.5).
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID string) {
// (design/16 §4.5) and mode with the turn's behavior mode (design/16 §5.1).
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) {
defer cancel()
res, err := o.run(ctx, sessionID, messages, env)
if err != nil && ctx.Err() == nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err, deviceID)
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode)
}
// completeTurn runs after a turn settles (either it finished, errored, or was
@@ -278,9 +282,10 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
// message "remembers" the prior context instead of starting from a blank slate
// (design/15 §2.2: incremental, not full replay). A stale generation (an
// interrupt or a newer top-level turn superseded this one) is a no-op.
// deviceID is the originating turn's client id, used to tag the persisted reply.
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID string) error {
o.persistAssistantReply(sessionID, res, deviceID)
// deviceID is the originating turn's client id and mode its behavior mode, both
// used to tag the persisted reply (design/16 §4.5, §5.1).
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode string) error {
o.persistAssistantReply(sessionID, res, deviceID, mode)
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
@@ -305,7 +310,7 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
o.mu.Unlock()
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel, next.deviceID)
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel, next.deviceID, next.mode)
return nil
}