From da60e435bea0e0ce4530a1f9b4ae315f982517f4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 11:21:40 +0000 Subject: [PATCH] fix(session): give host tools the product scope of their turn A harness may call a tool with a context that carries nothing of the turn. Lend the values of the turn to the tool, so a status read in the tool shares the turn's observation instead of starting without one. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/services/studio/session/chat.go | 20 ++++++++++++++++++- .../studio/session/productscope_test.go | 20 +++++++++++++++++++ .../studio/session/sessiontest/harness.go | 10 +++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/core/services/studio/session/chat.go b/core/services/studio/session/chat.go index 760e044f78..2b047ebed8 100644 --- a/core/services/studio/session/chat.go +++ b/core/services/studio/session/chat.go @@ -1390,7 +1390,10 @@ func (c *Chat) guardTools(tools []Tool) []Tool { if tctx == nil { tctx = c.m.ctx } - rctx, cancel := context.WithCancel(ctx) + // The harness context carries none of the product state of the turn + // (see Options.ProductScope), so a tool would not see it. Lend the + // values of the turn to the tool. + rctx, cancel := context.WithCancel(turnValues{Context: ctx, turn: tctx}) defer cancel() defer context.AfterFunc(tctx, cancel)() return run(rctx, args) @@ -1418,3 +1421,18 @@ func (c *Chat) releaseMemory(ctx context.Context) { c.bus = closedBus() c.evMu.Unlock() } + +// turnValues is a context whose values fall back to those of the turn: the +// product state ProductScope attached, which the harness does not pass on. +// Deadline and cancellation stay those of the embedded context. +type turnValues struct { + context.Context + turn context.Context +} + +func (t turnValues) Value(key any) any { + if v := t.Context.Value(key); v != nil { + return v + } + return t.turn.Value(key) +} diff --git a/core/services/studio/session/productscope_test.go b/core/services/studio/session/productscope_test.go index 116b878426..bf79a3c027 100644 --- a/core/services/studio/session/productscope_test.go +++ b/core/services/studio/session/productscope_test.go @@ -123,6 +123,26 @@ var _ = Describe("product request scopes", func() { Expect(got).To(Equal([]model.ObservationID{"1", "2", "2", "2", "3", "3", "3"})) }) + It("gives a tool the product scope of the turn when the harness calls it with its own context", func() { + seen := make(chan bool, 1) + o := session.Options{Harness: &sessiontest.FakeHarness{OwnToolContext: true, Turns: [][]sessiontest.Step{{{Tool: "status", Args: "{}"}}}}} + o.ProductScope = func(ctx context.Context, _ session.Access) (context.Context, func()) { + return context.WithValue(ctx, productScopeKey{}, &productScope{}), func() {} + } + o.ChatTools = func(context.Context, session.Access) (session.TurnTools, error) { + return session.TurnTools{Tools: []session.Tool{{Name: "status", Description: "status", Schema: json.RawMessage(`{"type":"object"}`), + Run: func(ctx context.Context, _ json.RawMessage) (string, error) { + _, ok := ctx.Value(productScopeKey{}).(*productScope) + seen <- ok + return "ok", nil + }}}}, nil + } + chat := newChat(o) + Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed()) + waitTurn(chat) + Expect(<-seen).To(BeTrue(), "the tool sees the scope of its turn") + }) + for _, stop := range []bool{false, true} { It(fmt.Sprintf("closes pending observations at the real turn boundary (stop=%t)", stop), func() { errors := make(chan error, 8) diff --git a/core/services/studio/session/sessiontest/harness.go b/core/services/studio/session/sessiontest/harness.go index 0afbffc25d..fef96d084b 100644 --- a/core/services/studio/session/sessiontest/harness.go +++ b/core/services/studio/session/sessiontest/harness.go @@ -44,6 +44,10 @@ type FakeHarness struct { // FailNextStarts makes that many of the next Starts fail. Set it while no // Start runs. FailNextStarts int + // OwnToolContext makes the harness call a tool with a context of its own, + // derived from nothing of the turn, as a harness that receives tool calls + // from another process does. + OwnToolContext bool mu sync.Mutex envs []session.HarnessEnv @@ -172,7 +176,11 @@ func (s *fakeSession) Send(ctx context.Context, text string) error { args := json.RawMessage(st.Args) ev := &session.ToolEvent{Name: st.Tool, Summary: session.Summarize(args)} s.env.Emit(session.Event{Kind: session.KindToolStart, Tool: ev}) - out, err := tl.Run(ctx, args) + toolCtx := ctx + if s.h.OwnToolContext { + toolCtx = context.Background() + } + out, err := tl.Run(toolCtx, args) end := *ev end.OK, end.Output = err == nil, session.CapText(out, 2048) if err != nil {